1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 01:37:53 +02:00

Compare commits

..

3 Commits

112 changed files with 3292 additions and 8331 deletions

View File

@@ -17,10 +17,6 @@ on:
required: false
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
pull-requests: write

View File

@@ -2,15 +2,15 @@ from __future__ import annotations
from bec_lib import bec_logger
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
logger = bec_logger.logger
def dock_area(
object_name: str | None = None, profile: str | None = None, start_empty: bool = False
) -> BECDockArea:
) -> AdvancedDockArea:
"""
Create an advanced dock area using Qt Advanced Docking System.
@@ -20,7 +20,7 @@ def dock_area(
start_empty(bool): If True, start with an empty dock area when loading specified profile.
Returns:
BECDockArea: The created advanced dock area.
AdvancedDockArea: The created advanced dock area.
Note:
The "general" profile is mandatory and will always exist. If manually deleted,
@@ -29,7 +29,7 @@ def dock_area(
# Default to "general" profile when called from CLI without specifying a profile
effective_profile = profile if profile is not None else "general"
widget = BECDockArea(
widget = AdvancedDockArea(
object_name=object_name,
restore_initial_profile=True,
root_widget=True,
@@ -51,7 +51,7 @@ def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates:
object_name(str): The name of the dock area.
Returns:
BECDockArea: The created dock area.
AdvancedDockArea: The created dock area.
"""
_auto_update = AutoUpdates(object_name=object_name)
return _auto_update

View File

@@ -27,12 +27,14 @@ 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.screen_utils import apply_window_geometry, centered_geometry_for_app
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
get_last_profile,
list_profiles,
)
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.dock_area.profile_utils import get_last_profile, list_profiles
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
@@ -76,28 +78,23 @@ class LaunchTile(RoundedFrame):
circular_pixmap.fill(Qt.transparent)
painter = QPainter(circular_pixmap)
painter.setRenderHints(QPainter.RenderHint.Antialiasing, True)
painter.setRenderHints(QPainter.Antialiasing, True)
path = QPainterPath()
path.addEllipse(0, 0, size, size)
painter.setClipPath(path)
pixmap = pixmap.scaled(
size,
size,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
painter.drawPixmap(0, 0, pixmap)
painter.end()
self.icon_label.setPixmap(circular_pixmap)
self.layout.addWidget(self.icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
self.layout.addWidget(self.icon_label, alignment=Qt.AlignCenter)
# Top label
self.top_label = QLabel(top_label.upper())
font_top = self.top_label.font()
font_top.setPointSize(10)
self.top_label.setFont(font_top)
self.layout.addWidget(self.top_label, alignment=Qt.AlignmentFlag.AlignCenter)
self.layout.addWidget(self.top_label, alignment=Qt.AlignCenter)
# Main label
self.main_label = QLabel(main_label)
@@ -107,7 +104,7 @@ class LaunchTile(RoundedFrame):
font_main.setPointSize(14)
font_main.setBold(True)
self.main_label.setFont(font_main)
self.main_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.main_label.setAlignment(Qt.AlignCenter)
# Shrink font if the default would wrap on this platform / DPI
content_width = (
@@ -123,13 +120,13 @@ class LaunchTile(RoundedFrame):
self.layout.addWidget(self.main_label)
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
self.layout.addItem(self.spacer_top)
# Description
self.description_label = QLabel(description)
self.description_label.setWordWrap(True)
self.description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.description_label.setAlignment(Qt.AlignCenter)
self.layout.addWidget(self.description_label)
# Selector
@@ -139,9 +136,7 @@ class LaunchTile(RoundedFrame):
else:
self.selector = None
self.spacer_bottom = QSpacerItem(
0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding
)
self.spacer_bottom = QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding)
self.layout.addItem(self.spacer_bottom)
# Action button
@@ -161,7 +156,7 @@ class LaunchTile(RoundedFrame):
}
"""
)
self.layout.addWidget(self.action_button, alignment=Qt.AlignmentFlag.AlignCenter)
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
"""
@@ -184,13 +179,12 @@ class LaunchTile(RoundedFrame):
metrics = QFontMetrics(font)
label.setFont(font)
label.setWordWrap(False)
label.setText(metrics.elidedText(label.text(), Qt.TextElideMode.ElideRight, max_width))
label.setText(metrics.elidedText(label.text(), Qt.ElideRight, max_width))
class LaunchWindow(BECMainWindow):
RPC = True
TILE_SIZE = (250, 300)
DEFAULT_LAUNCH_SIZE = (800, 600)
USER_ACCESS = ["show_launcher", "hide_launcher"]
def __init__(
@@ -215,7 +209,7 @@ class LaunchWindow(BECMainWindow):
self.toolbar = ModularToolBar(parent=self)
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
self.spacer = QWidget(self)
self.spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
@@ -324,7 +318,7 @@ class LaunchWindow(BECMainWindow):
)
tile.setFixedWidth(self.TILE_SIZE[0])
tile.setMinimumHeight(self.TILE_SIZE[1])
tile.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.MinimumExpanding)
tile.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding)
if action_button:
tile.action_button.clicked.connect(action_button)
if show_selector and selector_items:
@@ -434,9 +428,7 @@ class LaunchWindow(BECMainWindow):
from bec_widgets.applications import bw_launch
with RPCRegister.delayed_broadcast() as rpc_register:
if geometry is None and launch_script != "custom_ui_file":
geometry = self._default_launch_geometry()
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(AdvancedDockArea)
if name is not None:
WidgetContainerUtils.raise_for_invalid_name(name)
# If name already exists, generate a unique one with counter suffix
@@ -459,13 +451,13 @@ class LaunchWindow(BECMainWindow):
if launch_script == "auto_update":
auto_update = kwargs.pop("auto_update", None)
return self._launch_auto_update(auto_update, geometry=geometry)
return self._launch_auto_update(auto_update)
if launch_script == "widget":
widget = kwargs.pop("widget", None)
if widget is None:
raise ValueError("Widget name must be provided.")
return self._launch_widget(widget, geometry=geometry)
return self._launch_widget(widget)
launch = getattr(bw_launch, launch_script, None)
if launch is None:
@@ -477,13 +469,13 @@ class LaunchWindow(BECMainWindow):
logger.info(f"Created new dock area: {name}")
if isinstance(result_widget, BECMainWindow):
apply_window_geometry(result_widget, geometry)
self._apply_window_geometry(result_widget, geometry)
result_widget.show()
else:
window = BECMainWindowNoRPC()
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
apply_window_geometry(window, geometry)
self._apply_window_geometry(window, geometry)
window.show()
return result_widget
@@ -518,15 +510,14 @@ class LaunchWindow(BECMainWindow):
window = BECMainWindow(object_name=filename)
window.setCentralWidget(loaded)
QApplication.processEvents()
window.setWindowTitle(f"BEC - {filename}")
apply_window_geometry(window, None)
self._apply_window_geometry(window, None)
window.show()
logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}")
return window
def _launch_auto_update(
self, auto_update: str, geometry: tuple[int, int, int, int] | None = None
) -> AutoUpdates:
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
if auto_update in self.available_auto_updates:
auto_update_cls = self.available_auto_updates[auto_update]
window = auto_update_cls()
@@ -536,14 +527,13 @@ class LaunchWindow(BECMainWindow):
window = AutoUpdates()
window.resize(window.minimumSizeHint())
QApplication.processEvents()
window.setWindowTitle(f"BEC - {window.objectName()}")
apply_window_geometry(window, geometry)
self._apply_window_geometry(window, None)
window.show()
return window
def _launch_widget(
self, widget: type[BECWidget], geometry: tuple[int, int, int, int] | None = None
) -> QWidget:
def _launch_widget(self, widget: type[BECWidget]) -> QWidget:
name = pascal_to_snake(widget.__name__)
WidgetContainerUtils.raise_for_invalid_name(name)
@@ -552,11 +542,12 @@ class LaunchWindow(BECMainWindow):
widget_instance = widget(root_widget=True, object_name=name)
assert isinstance(widget_instance, QWidget)
QApplication.processEvents()
window.setCentralWidget(widget_instance)
window.resize(window.minimumSizeHint())
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
apply_window_geometry(window, geometry)
self._apply_window_geometry(window, None)
window.show()
return window
@@ -604,9 +595,30 @@ class LaunchWindow(BECMainWindow):
raise ValueError(f"Widget {widget} not found in available widgets.")
return self.launch("widget", widget=self.available_widgets[widget])
def _default_launch_geometry(self) -> tuple[int, int, int, int] | None:
width, height = self.DEFAULT_LAUNCH_SIZE
return centered_geometry_for_app(width=width, height=height)
def _apply_window_geometry(
self, window: QWidget, geometry: tuple[int, int, int, int] | None
) -> None:
"""Apply a provided geometry or center the window with an 80% layout."""
if geometry is not None:
window.setGeometry(*geometry)
return
default_geometry = self._default_window_geometry(window)
if default_geometry is not None:
window.setGeometry(*default_geometry)
else:
window.resize(window.minimumSizeHint())
@staticmethod
def _default_window_geometry(window: QWidget) -> tuple[int, int, int, int] | None:
screen = window.screen() or QApplication.primaryScreen()
if screen is None:
return None
available = screen.availableGeometry()
width = int(available.width() * 0.8)
height = int(available.height() * 0.8)
x = available.x() + (available.width() - width) // 2
y = available.y() + (available.height() - height) // 2
return x, y, width, height
@SafeSlot(popup_error=True)
def _open_custom_ui_file(self):
@@ -653,19 +665,10 @@ class LaunchWindow(BECMainWindow):
Check if the launcher is the last widget in the application.
"""
# get all parents of connections
for connection in connections.values():
try:
parent = connection.parent()
if parent is None and connection.objectName() != self.objectName():
logger.info(
f"Found non-launcher connection without parent: {connection.objectName()}"
)
return False
except Exception as e:
logger.error(f"Error getting parent of connection: {e}")
return False
return True
remaining_connections = [
connection for connection in connections.values() if connection.parent_id != self.gui_id
]
return len(remaining_connections) <= 4
def _turn_off_the_lights(self, connections: dict):
"""
@@ -697,7 +700,7 @@ class LaunchWindow(BECMainWindow):
self.hide()
if __name__ == "__main__": # pragma: no cover
if __name__ == "__main__":
import sys
from bec_widgets.utils.colors import apply_theme

View File

@@ -7,12 +7,7 @@ from bec_widgets.applications.views.developer_view.developer_view import Develop
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.screen_utils import (
apply_centered_size,
available_screen_geometry,
main_app_size_for_screen,
)
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
@@ -50,7 +45,7 @@ class BECMainApp(BECMainWindow):
def _add_views(self):
self.add_section("BEC Applications", "bec_apps")
self.ads = BECDockArea(self, profile_namespace="bec", auto_profile_namespace=False)
self.ads = AdvancedDockArea(self, profile_namespace="bec", auto_profile_namespace=False)
self.ads.setObjectName("MainWorkspace")
self.device_manager = DeviceManagerView(self)
self.developer_view = DeveloperView(self)
@@ -216,12 +211,25 @@ def main(): # pragma: no cover
apply_theme("dark")
w = BECMainApp(show_examples=args.examples)
screen_geometry = available_screen_geometry()
if screen_geometry is not None:
width, height = main_app_size_for_screen(screen_geometry)
apply_centered_size(w, width, height, available=screen_geometry)
else:
w.resize(w.minimumSizeHint())
screen = app.primaryScreen()
screen_geometry = screen.availableGeometry()
screen_width = screen_geometry.width()
screen_height = screen_geometry.height()
# 70% of screen height, keep 16:9 ratio
height = int(screen_height * 0.9)
width = int(height * (16 / 9))
# If width exceeds screen width, scale down
if width > screen_width * 0.9:
width = int(screen_width * 0.9)
height = int(width / (16 / 9))
w.resize(width, height)
# Center the window on the screen
x = screen_geometry.x() + (screen_geometry.width() - width) // 2
y = screen_geometry.y() + (screen_geometry.height() - height) // 2
w.move(x, y)
w.show()

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget

View File

@@ -13,8 +13,8 @@ from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.qt_ads import CDockWidget
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
@@ -99,7 +99,7 @@ class DeveloperWidget(DockAreaWidget):
self.monaco = MonacoDock(self)
self.monaco.setObjectName("MonacoEditor")
self.monaco.save_enabled.connect(self._on_save_enabled_update)
self.plotting_ads = BECDockArea(
self.plotting_ads = AdvancedDockArea(
self,
mode="plot",
default_add_direction="bottom",

View File

@@ -56,6 +56,8 @@ class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
if device_name:
self.device_manager_ophyd_test.add_device_to_keep_visible_after_validation(device_name)
self.device_manager_ophyd_test.change_device_configs([config], True, True)
# Dialog Buttons: equal size, stacked horizontally
button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Close)
for button in button_box.buttons():
@@ -69,9 +71,6 @@ class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
self._resize_dialog()
self.finished.connect(self._finished)
# Add and test device config
self.device_manager_ophyd_test.change_device_configs([config], added=True, connect=True)
def _resize_dialog(self):
"""Resize the dialog based on the screen size."""
app: QtCore.QCoreApplication = QtWidgets.QApplication.instance()
@@ -176,17 +175,12 @@ class DeviceFormDialog(QtWidgets.QDialog):
self.cancel_btn = QtWidgets.QPushButton("Cancel")
self.reset_btn = QtWidgets.QPushButton("Reset Form")
btn_box = QtWidgets.QDialogButtonBox(self)
btn_box.addButton(self.cancel_btn, QtWidgets.QDialogButtonBox.ButtonRole.RejectRole)
btn_box.addButton(self.reset_btn, QtWidgets.QDialogButtonBox.ButtonRole.ActionRole)
btn_box.addButton(
self.test_connection_btn, QtWidgets.QDialogButtonBox.ButtonRole.ActionRole
)
btn_box.addButton(self.add_btn, QtWidgets.QDialogButtonBox.ButtonRole.AcceptRole)
for btn in btn_box.buttons():
btn_layout = QtWidgets.QHBoxLayout()
for btn in (self.cancel_btn, self.reset_btn, self.test_connection_btn, self.add_btn):
btn.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
layout.addWidget(btn_box)
btn_layout.addWidget(btn)
btn_box = QtWidgets.QGroupBox("Actions")
btn_box.setLayout(btn_layout)
frame_layout.addWidget(btn_box)
# Connect signals to explicit slots
@@ -291,7 +285,7 @@ class DeviceFormDialog(QtWidgets.QDialog):
The dialog will be modal and prevent user interaction until validation is complete.
"""
wait_dialog = QtWidgets.QProgressDialog(
"Validating config... please wait", None, 0, 0, parent=self
"Validating config please wait", None, 0, 0, parent=self
)
wait_dialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
wait_dialog.setCancelButton(None)
@@ -374,7 +368,7 @@ class DeviceFormDialog(QtWidgets.QDialog):
if not validate_name(config.get("name", "")):
msg_box = self._create_warning_message_box(
"Invalid Device Name",
f"Device is invalid, cannot be empty or contain spaces. Please provide a valid name. {config.get('name', '')!r}",
f"Device is invalid, can not be empty with spaces. Please provide a valid name. {config.get('name', '')!r} ",
)
msg_box.exec()
return

View File

@@ -513,8 +513,7 @@ class UploadRedisDialog(QtWidgets.QDialog):
[
detailed_text,
"These devices may not be reachable and disabled BEC upon loading the config.",
"Consider validating these connections before proceeding.\n\n",
"Continue anyway?",
"Consider validating these connections before.",
]
)
reply = QtWidgets.QMessageBox.critical(

View File

@@ -2,29 +2,18 @@ from __future__ import annotations
import os
from functools import partial
from typing import TYPE_CHECKING, List, Literal, get_args
from typing import List, Literal, get_args
import yaml
from bec_lib import config_helper
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.callback_handler import EventType
from bec_lib.endpoints import MessageEndpoints
from bec_lib.file_utils import DeviceConfigWriter
from bec_lib.logger import bec_logger
from bec_lib.messages import ConfigAction, ScanStatusMessage
from bec_lib.messages import ConfigAction
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from bec_qthemes import apply_theme, material_icon
from qtpy.QtCore import QMetaObject, Qt, QThreadPool, Signal
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QApplication,
QFileDialog,
QMessageBox,
QPushButton,
QTextEdit,
QVBoxLayout,
QWidget,
)
from bec_qthemes import apply_theme
from qtpy.QtCore import QMetaObject, QThreadPool, Signal
from qtpy.QtWidgets import QFileDialog, QMessageBox, QTextEdit, QVBoxLayout, QWidget
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import (
ConfigChoiceDialog,
@@ -33,12 +22,11 @@ from bec_widgets.applications.views.device_manager_view.device_manager_dialogs i
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import (
UploadRedisDialog,
)
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.control.device_manager.components import (
DeviceTable,
DMConfigView,
@@ -50,16 +38,9 @@ from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophy
ConfigStatus,
ConnectionStatus,
)
from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import (
DeviceInitializationProgressBar,
)
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
CommunicateConfigAction,
)
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
if TYPE_CHECKING: # pragma: no cover
from bec_lib.client import BECClient
logger = bec_logger.logger
@@ -70,88 +51,6 @@ _yes_no_question = partial(
)
class CustomBusyWidget(QWidget):
"""Custom busy widget to show during device config upload."""
cancel_requested = Signal()
def __init__(self, parent=None, client: BECClient | None = None):
super().__init__(parent=parent)
# Widgets
self.progress = QWidget(parent=self)
self.progress_layout = QVBoxLayout(self.progress)
self.progress_layout.setContentsMargins(6, 6, 6, 6)
self.progress_inner = DeviceInitializationProgressBar(parent=self.progress, client=client)
self.progress_layout.addWidget(self.progress_inner)
self.progress.setMinimumWidth(320)
# Spinner
self.spinner = SpinnerWidget(parent=self)
scale = self._ui_scale()
spinner_size = int(scale * 0.12) if scale else 1
spinner_size = max(32, min(spinner_size, 96))
self.spinner.setFixedSize(spinner_size, spinner_size)
# Cancel button
self.cancel_button = QPushButton("Cancel Upload", parent=self)
self.cancel_button.setIcon(material_icon("cancel"))
self.cancel_button.clicked.connect(self.cancel_requested.emit)
button_height = int(spinner_size * 0.9)
button_height = max(36, min(button_height, 72))
aspect_ratio = 3.8 # width / height, visually stable for text buttons
button_width = int(button_height * aspect_ratio)
self.cancel_button.setFixedSize(button_width, button_height)
color = get_accent_colors()
self.cancel_button.setStyleSheet(
f"""
QPushButton {{
background-color: {color.emergency.name()};
color: white;
font-weight: 600;
border-radius: 6px;
}}
"""
)
# Layout
content_layout = QVBoxLayout(self)
content_layout.setContentsMargins(24, 24, 24, 24)
content_layout.setSpacing(16)
content_layout.addStretch()
content_layout.addWidget(self.spinner, 0, Qt.AlignmentFlag.AlignHCenter)
content_layout.addWidget(self.progress, 0, Qt.AlignmentFlag.AlignHCenter)
content_layout.addStretch()
content_layout.addWidget(self.cancel_button, 0, Qt.AlignmentFlag.AlignHCenter)
if hasattr(color, "_colors"):
bg_color = color._colors.get("BG", None)
if bg_color is None: # Fallback if missing
bg_color = QColor(50, 50, 50, 255)
self.setStyleSheet(
f"""
background-color: {bg_color.name()};
border-radius: 12px;
"""
)
def _ui_scale(self) -> int:
parent = self.parent()
if not parent:
return 0
return min(parent.width(), parent.height())
def showEvent(self, event):
"""Show event to start the spinner."""
super().showEvent(event)
self.spinner.start()
def hideEvent(self, event):
"""Hide event to stop the spinner."""
super().hideEvent(event)
self.spinner.stop()
class DeviceManagerDisplayWidget(DockAreaWidget):
"""Device Manager main display widget. This contains all sub-widgets and the toolbar."""
@@ -162,20 +61,10 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, variant="compact", *args, **kwargs)
# State variable for config upload
self._config_upload_active: bool = False
self._config_in_sync: bool = False
scan_status = self.bec_dispatcher.client.connector.get(MessageEndpoints.scan_status())
initial_status = scan_status.status if scan_status is not None else "closed"
self._scan_is_running: bool = initial_status in ["open", "paused"]
# Push to Redis dialog
self._upload_redis_dialog: UploadRedisDialog | None = None
self._dialog_validation_connection: QMetaObject.Connection | None = None
# NOTE: We need here a seperate config helper instance to avoid conflicts with
# other communications to REDIS as uploading a config through a CommunicationConfigAction
# will block if we use the config_helper from self.client.config._config_helper
self._config_helper = config_helper.ConfigHelper(self.client.connector)
self._shared_selection = SharedSelectionSignal()
@@ -223,58 +112,19 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
),
(
self.device_table_view.device_config_in_sync_with_redis,
(self._update_config_in_sync,),
(self._update_config_enabled_button,),
),
(self.device_table_view.device_row_dbl_clicked, (self._edit_device_action,)),
]:
for slot in slots:
signal.connect(slot)
self._scan_status_callback_id = self.bec_dispatcher.client.callbacks.register(
EventType.SCAN_STATUS, self._update_scan_running
)
# Add toolbar
self._add_toolbar()
# Build dock layout using shared helpers
self._build_docks()
def cleanup(self):
self.bec_dispatcher.client.callbacks.remove(self._scan_status_callback_id)
super().cleanup()
def closeEvent(self, event):
"""If config upload is active when application is exiting, cancel it."""
logger.info("Application is quitting, checking for active config upload...")
if self._config_upload_active:
logger.info("Application is quitting, cancelling active config upload...")
self._config_helper.send_config_request(
action="cancel", config=None, wait_for_response=True, timeout_s=10
)
logger.info("Config upload cancelled.")
super().closeEvent(event)
##############################
### Custom set busy widget ###
##############################
def create_busy_state_widget(self) -> QWidget:
"""Create a custom busy state widget for uploading device configurations."""
widget = CustomBusyWidget(parent=self, client=self.client)
widget.cancel_requested.connect(self._cancel_device_config_upload)
return widget
def _set_busy_wrapper(self, enabled: bool):
"""Thin wrapper around set_busy to flip the state variable."""
self._busy_overlay.set_opacity(0.92)
self._config_upload_active = enabled
self.set_busy(enabled=enabled)
##############################
### Toolbar and Dock setup ###
##############################
def _add_toolbar(self):
self.toolbar = ModularToolBar(self)
@@ -456,36 +306,6 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
# Add load config from plugin dir
self.toolbar.add_bundle(table_bundle)
######################################
### Update button state management ###
######################################
@SafeSlot(dict, dict)
def _update_scan_running(self, scan_info: dict, _: dict):
"""disable editing when scans are running and enable editing when they are finished"""
msg = ScanStatusMessage.model_validate(scan_info)
self._scan_is_running = msg.status in ["open", "paused"]
self._update_config_enabled_button()
def _update_config_in_sync(self, in_sync: bool):
self._config_in_sync = in_sync
self._update_config_enabled_button()
def _update_config_enabled_button(self):
action = self.toolbar.components.get_action("update_config_redis")
enabled = not self._config_in_sync and not self._scan_is_running
action.action.setEnabled(enabled)
if enabled: # button is enabled
action.action.setToolTip("Push current config to BEC Server")
elif self._scan_is_running:
action.action.setToolTip("Scan is currently running, config updates disabled.")
else:
action.action.setToolTip("Current config is in sync with BEC Server, updates disabled.")
#######################
### Action Handlers ###
#######################
@SafeSlot()
@SafeSlot(bool)
def _run_validate_connection(self, connect: bool = True):
@@ -502,6 +322,14 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
)
self.request_ophyd_validation.emit(configs, True, connect)
def _update_config_enabled_button(self, enabled: bool):
action = self.toolbar.components.get_action("update_config_redis")
action.action.setEnabled(not enabled)
if enabled:
action.action.setToolTip("Push current config to BEC Server")
else:
action.action.setToolTip("Current config is in sync with BEC Server, button disabled.")
@SafeSlot()
def _load_file_action(self):
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
@@ -604,8 +432,10 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
"Do you really want to flush the current config in BEC Server?",
)
if reply == QMessageBox.StandardButton.Yes:
self.set_busy(enabled=True, text="Flushing configuration in BEC Server...")
self.client.config.reset_config()
logger.info("Successfully flushed configuration in BEC Server.")
self.set_busy(enabled=False)
# Check if config is in sync, enable load redis button
self.device_table_view.device_config_in_sync_with_redis.emit(
self.device_table_view._is_config_in_sync_with_redis()
@@ -681,37 +511,12 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
comm.signals.done.connect(self._handle_push_complete_to_communicator)
comm.signals.error.connect(self._handle_exception_from_communicator)
threadpool.start(comm)
self._set_busy_wrapper(enabled=True)
def _cancel_device_config_upload(self):
"""Cancel the device configuration upload process."""
threadpool = QThreadPool.globalInstance()
comm = CommunicateConfigAction(self._config_helper, None, {}, "cancel")
# Cancelling will raise an exception in the communicator, so we connect to the failure handler
comm.signals.error.connect(self._handle_cancel_config_upload_failed)
threadpool.start(comm)
def _handle_cancel_config_upload_failed(self, exception: Exception):
"""Handle failure to cancel the config upload."""
self._set_busy_wrapper(enabled=False)
validation_results = self.device_table_view.get_validation_results()
devices_to_update = []
for config, config_status, connection_status in validation_results.values():
devices_to_update.append(
(config, config_status, ConnectionStatus.UNKNOWN.value, "Upload Cancelled")
)
# Rerun validation of all devices after cancellation
self.device_table_view.update_multiple_device_validations(devices_to_update)
self.ophyd_test_view.change_device_configs(
[cfg for cfg, _, _, _ in devices_to_update], added=True, skip_validation=False
)
# Config is in sync with BEC, so we update the state
self.device_table_view.device_config_in_sync_with_redis.emit(False)
self.set_busy(enabled=True, text="Uploading configuration to BEC Server...")
def _handle_push_complete_to_communicator(self):
"""Handle completion of the config push to Redis."""
self._set_busy_wrapper(enabled=False)
self.set_busy(enabled=False)
self._update_validation_icons_after_upload()
def _handle_exception_from_communicator(self, exception: Exception):
"""Handle exceptions from the config communicator."""
@@ -720,7 +525,29 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
"Error Uploading Config",
f"An error occurred while uploading the configuration to BEC Server:\n{str(exception)}",
)
self._set_busy_wrapper(enabled=False)
self.set_busy(enabled=False)
self._update_validation_icons_after_upload()
def _update_validation_icons_after_upload(self):
"""Update validation icons after uploading config to Redis."""
if self.client.device_manager is None:
return
device_names_in_session = list(self.client.device_manager.devices.keys())
validation_results = self.device_table_view.get_validation_results()
devices_to_update = []
for config, config_status, connection_status in validation_results.values():
if config["name"] in device_names_in_session:
devices_to_update.append(
(config, config_status, ConnectionStatus.CONNECTED.value, "")
)
# Update validation status in device table view
self.device_table_view.update_multiple_device_validations(devices_to_update)
# Remove devices from ophyd validation view
self.ophyd_test_view.change_device_configs(
[cfg for cfg, _, _, _ in devices_to_update], added=False, skip_validation=True
)
# Config is in sync with BEC, so we update the state
self.device_table_view.device_config_in_sync_with_redis.emit(True)
@SafeSlot()
def _save_to_disk_action(self):
@@ -786,7 +613,8 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
):
if old_device_name and old_device_name != data.get("name", ""):
self.device_table_view.remove_device(old_device_name)
self._add_to_table_from_dialog(data, config_status, connection_status, msg, old_device_name)
self.device_table_view.update_device_configs([data], skip_validation=True)
self.device_table_view.update_device_validation(data, config_status, connection_status, msg)
@SafeSlot(dict, int, int, str, str)
def _add_to_table_from_dialog(
@@ -797,15 +625,8 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
msg: str,
old_device_name: str = "",
):
if connection_status == ConnectionStatus.UNKNOWN.value:
self.device_table_view.update_device_configs([data], skip_validation=False)
else: # Connection status was tested in dialog
# If device is connected, we remove it from the ophyd validation view
self.device_table_view.update_device_configs([data], skip_validation=True)
# Update validation status in device table view and ophyd validation view
self.ophyd_test_view._on_device_test_completed(
data, config_status, connection_status, msg
)
self.device_table_view.add_device_configs([data], skip_validation=True)
self.device_table_view.update_device_validation(data, config_status, connection_status, msg)
@SafeSlot()
def _remove_device_action(self):

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
from qtpy.QtCore import QEventLoop
from typing import List
from qtpy.QtCore import QEventLoop, Qt, QTimer
from qtpy.QtWidgets import (
QDialog,
QDialogButtonBox,
@@ -9,18 +11,54 @@ from qtpy.QtWidgets import (
QLabel,
QMessageBox,
QPushButton,
QSplitter,
QStackedLayout,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.status_bar import StatusToolBar
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.plots.waveform.waveform import Waveform
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
"""
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
Works for horizontal or vertical splitters and sets matching stretch factors.
"""
def apply():
n = splitter.count()
if n == 0:
return
w = list(weights[:n]) + [1] * max(0, n - len(weights))
w = [max(0.0, float(x)) for x in w]
tot_w = sum(w)
if tot_w <= 0:
w = [1.0] * n
tot_w = float(n)
total_px = (
splitter.width()
if splitter.orientation() == Qt.Orientation.Horizontal
else splitter.height()
)
if total_px < 2:
QTimer.singleShot(0, apply)
return
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
diff = total_px - sum(sizes)
if diff != 0:
idx = max(range(n), key=lambda i: w[i])
sizes[idx] = max(1, sizes[idx] + diff)
splitter.setSizes(sizes)
for i, wi in enumerate(w):
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
QTimer.singleShot(0, apply)
class ViewBase(QWidget):
"""Wrapper for a content widget used inside the main app's stacked view.
@@ -31,7 +69,6 @@ class ViewBase(QWidget):
parent (QWidget | None): Parent widget.
id (str | None): Optional view id, useful for debugging or introspection.
title (str | None): Optional human-readable title.
show_status (bool): Whether to show a status toolbar at the top of the view.
"""
def __init__(
@@ -41,8 +78,6 @@ class ViewBase(QWidget):
*,
id: str | None = None,
title: str | None = None,
show_status: bool = False,
status_names: list[str] | None = None,
):
super().__init__(parent=parent)
self.content: QWidget | None = None
@@ -53,48 +88,15 @@ class ViewBase(QWidget):
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
self.status_bar: StatusToolBar | None = None
if show_status:
# If explicit status names are provided, default to showing only those.
show_all = status_names is None
self.setup_status_bar(show_all_status=show_all, status_names=status_names)
if content is not None:
self.set_content(content)
def set_content(self, content: QWidget) -> None:
"""Replace the current content widget with a new one."""
if self.content is not None:
self.layout().removeWidget(self.content)
self.content.setParent(None)
self.content.close()
self.content.deleteLater()
self.content = content
if self.status_bar is not None:
insert_at = self.layout().indexOf(self.status_bar) + 1
self.layout().insertWidget(insert_at, content)
else:
self.layout().addWidget(content)
def setup_status_bar(
self, *, show_all_status: bool = True, status_names: list[str] | None = None
) -> None:
"""Create and attach a status toolbar managed by the status broker."""
if self.status_bar is not None:
return
names_arg = None if show_all_status else status_names
self.status_bar = StatusToolBar(parent=self, names=names_arg)
self.layout().addWidget(self.status_bar)
def set_status(
self, name: str = "main", *, state=None, text: str | None = None, tooltip: str | None = None
) -> None:
"""Manually set a status item on the status bar."""
if self.status_bar is None:
self.setup_status_bar(show_all_status=True)
if self.status_bar is None:
return
self.status_bar.set_status(name=name, state=state, text=text, tooltip=tooltip)
self.layout().addWidget(content)
@SafeSlot()
def on_enter(self) -> None:
@@ -113,6 +115,68 @@ class ViewBase(QWidget):
"""
return True
####### Default view has to be done with setting up splitters ########
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
"""Apply initial weights to every horizontal and vertical splitter.
Examples:
horizontal_weights = [1, 3, 2, 1]
vertical_weights = [3, 7] # top:bottom = 30:70
"""
splitters_h = []
splitters_v = []
for splitter in self.findChildren(QSplitter):
if splitter.orientation() == Qt.Orientation.Horizontal:
splitters_h.append(splitter)
elif splitter.orientation() == Qt.Orientation.Vertical:
splitters_v.append(splitter)
def apply_all():
for s in splitters_h:
set_splitter_weights(s, horizontal_weights)
for s in splitters_v:
set_splitter_weights(s, vertical_weights)
QTimer.singleShot(0, apply_all)
def set_stretch(self, *, horizontal=None, vertical=None):
"""Update splitter weights and re-apply to all splitters.
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
for convenience: horizontal roles = {"left","center","right"},
vertical roles = {"top","bottom"}.
"""
def _coerce_h(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [
float(x.get("left", 1)),
float(x.get("center", x.get("middle", 1))),
float(x.get("right", 1)),
]
return None
def _coerce_v(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
return None
h = _coerce_h(horizontal)
v = _coerce_v(vertical)
if h is None:
h = [1, 1, 1]
if v is None:
v = [1, 1]
self.set_default_view(h, v)
####################################################################################################
# Example views for demonstration/testing purposes

View File

@@ -35,6 +35,8 @@ _Widgets = {
"DapComboBox": "DapComboBox",
"DarkModeButton": "DarkModeButton",
"DeviceBrowser": "DeviceBrowser",
"DeviceComboBox": "DeviceComboBox",
"DeviceLineEdit": "DeviceLineEdit",
"Heatmap": "Heatmap",
"Image": "Image",
"LogPanel": "LogPanel",
@@ -54,8 +56,11 @@ _Widgets = {
"ScanControl": "ScanControl",
"ScanProgressBar": "ScanProgressBar",
"ScatterWaveform": "ScatterWaveform",
"SignalComboBox": "SignalComboBox",
"SignalLabel": "SignalLabel",
"SignalLineEdit": "SignalLineEdit",
"TextBox": "TextBox",
"VSCodeEditor": "VSCodeEditor",
"Waveform": "Waveform",
"WebConsole": "WebConsole",
"WebsiteWidget": "WebsiteWidget",
@@ -90,63 +95,7 @@ except ImportError as e:
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
class AutoUpdates(RPCBase):
@property
@rpc_call
def enabled(self) -> "bool":
"""
Get the enabled status of the auto updates.
"""
@enabled.setter
@rpc_call
def enabled(self) -> "bool":
"""
Get the enabled status of the auto updates.
"""
@property
@rpc_call
def selected_device(self) -> "str | None":
"""
Get the selected device from the auto update config.
Returns:
str: The selected device. If no device is selected, None is returned.
"""
@selected_device.setter
@rpc_call
def selected_device(self) -> "str | None":
"""
Get the selected device from the auto update config.
Returns:
str: The selected device. If no device is selected, None is returned.
"""
class AvailableDeviceResources(RPCBase):
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class BECDockArea(RPCBase):
class AdvancedDockArea(RPCBase):
@rpc_call
def new(
self,
@@ -376,6 +325,62 @@ class BECDockArea(RPCBase):
"""
class AutoUpdates(RPCBase):
@property
@rpc_call
def enabled(self) -> "bool":
"""
Get the enabled status of the auto updates.
"""
@enabled.setter
@rpc_call
def enabled(self) -> "bool":
"""
Get the enabled status of the auto updates.
"""
@property
@rpc_call
def selected_device(self) -> "str | None":
"""
Get the selected device from the auto update config.
Returns:
str: The selected device. If no device is selected, None is returned.
"""
@selected_device.setter
@rpc_call
def selected_device(self) -> "str | None":
"""
Get the selected device from the auto update config.
Returns:
str: The selected device. If no device is selected, None is returned.
"""
class AvailableDeviceResources(RPCBase):
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class BECMainWindow(RPCBase):
@rpc_call
def remove(self):
@@ -1043,25 +1048,26 @@ class DeviceBrowser(RPCBase):
"""
class DeviceInitializationProgressBar(RPCBase):
"""A progress bar that displays the progress of device initialization."""
class DeviceComboBox(RPCBase):
"""Combobox widget for device input with autocomplete for device names."""
@rpc_call
def remove(self):
def set_device(self, device: "str"):
"""
Cleanup the BECConnector
Set the device.
Args:
device (str): Default name.
"""
@property
@rpc_call
def attach(self):
"""
None
def devices(self) -> "list[str]":
"""
Get the list of devices for the applied filters.
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
Returns:
list[str]: List of devices.
"""
@@ -1087,6 +1093,39 @@ class DeviceInputBase(RPCBase):
"""
class DeviceLineEdit(RPCBase):
"""Line edit widget for device input with autocomplete for device names."""
@rpc_call
def set_device(self, device: "str"):
"""
Set the device.
Args:
device (str): Default name.
"""
@property
@rpc_call
def devices(self) -> "list[str]":
"""
Get the list of devices for the applied filters.
Returns:
list[str]: List of devices.
"""
@property
@rpc_call
def _is_valid_input(self) -> bool:
"""
Check if the current value is a valid device name.
Returns:
bool: True if the current value is a valid device name, False otherwise.
"""
class DockAreaWidget(RPCBase):
"""Lightweight dock area that exposes the core Qt ADS docking helpers without any"""
@@ -2501,30 +2540,16 @@ class Image(RPCBase):
@property
@rpc_call
def device_name(self) -> "str":
def monitor(self) -> "str":
"""
The name of the device to monitor for image data.
The name of the monitor to use for the image.
"""
@device_name.setter
@monitor.setter
@rpc_call
def device_name(self) -> "str":
def monitor(self) -> "str":
"""
The name of the device to monitor for image data.
"""
@property
@rpc_call
def device_entry(self) -> "str":
"""
The signal/entry name to monitor on the device.
"""
@device_entry.setter
@rpc_call
def device_entry(self) -> "str":
"""
The signal/entry name to monitor on the device.
The name of the monitor to use for the image.
"""
@rpc_call
@@ -2630,8 +2655,8 @@ class Image(RPCBase):
@rpc_call
def image(
self,
device_name: "str | None" = None,
device_entry: "str | None" = None,
monitor: "str | tuple | None" = None,
monitor_type: "Literal['auto', '1d', '2d']" = "auto",
color_map: "str | None" = None,
color_bar: "Literal['simple', 'full'] | None" = None,
vrange: "tuple[int, int] | None" = None,
@@ -2640,14 +2665,14 @@ class Image(RPCBase):
Set the image source and update the image.
Args:
device_name(str|None): The name of the device to monitor. If None or empty string, the current monitor will be disconnected.
device_entry(str|None): The signal/entry name to monitor on the device.
monitor(str|tuple|None): The name of the monitor to use for the image, or a tuple of (device, signal) for preview signals. If None or empty string, the current monitor will be disconnected.
monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto".
color_map(str): The color map to use for the image.
color_bar(str): The type of color bar to use. Options are "simple" or "full".
vrange(tuple): The range of values to use for the color map.
Returns:
ImageItem: The image object, or None if connection failed.
ImageItem: The image object.
"""
@property
@@ -4666,6 +4691,29 @@ class ResumeButton(RPCBase):
class Ring(RPCBase):
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def set_value(self, value: "int | float"):
"""
@@ -4685,24 +4733,14 @@ class Ring(RPCBase):
"""
@rpc_call
def set_background(self, color: "str | tuple | QColor"):
def set_background(self, color: "str | tuple"):
"""
Set the background color for the ring widget. The background color is only used when colors are not linked.
Set the background color for the ring widget
Args:
color(str | tuple): Background color for the ring widget. Can be HEX code or tuple (R, G, B, A).
"""
@rpc_call
def set_colors_linked(self, linked: "bool"):
"""
Set whether the colors are linked for the ring widget.
If colors are linked, changing the main color will also change the background color.
Args:
linked(bool): Whether to link the colors for the ring widget
"""
@rpc_call
def set_line_width(self, width: "int"):
"""
@@ -4725,16 +4763,14 @@ class Ring(RPCBase):
@rpc_call
def set_start_angle(self, start_angle: "int"):
"""
Set the start angle for the ring widget.
Set the start angle for the ring widget
Args:
start_angle(int): Start angle for the ring widget in degrees
"""
@rpc_call
def set_update(
self, mode: "Literal['manual', 'scan', 'device']", device: "str" = "", signal: "str" = ""
):
def set_update(self, mode: "Literal['manual', 'scan', 'device']", device: "str" = None):
"""
Set the update mode for the ring widget.
Modes:
@@ -4745,24 +4781,193 @@ class Ring(RPCBase):
Args:
mode(str): Update mode for the ring widget. Can be "manual", "scan" or "device"
device(str): Device name for the device readback mode, only used when mode is "device"
signal(str): Signal name for the device readback mode, only used when mode is "device"
"""
@rpc_call
def set_precision(self, precision: "int"):
def reset_connection(self):
"""
Set the precision for the ring widget.
Args:
precision(int): Precision for the ring widget
Reset the connections for the ring widget. Disconnect the current slot and endpoint.
"""
class RingProgressBar(RPCBase):
"""Show the progress of devices, scans or custom values in the form of ring progress bars."""
@rpc_call
def remove(self):
def _get_all_rpc(self) -> "dict":
"""
Cleanup the BECConnector
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@property
@rpc_call
def rings(self) -> "list[Ring]":
"""
Returns a list of all rings in the progress bar.
"""
@rpc_call
def update_config(self, config: "RingProgressBarConfig | dict"):
"""
Update the configuration of the widget.
Args:
config(SpiralProgressBarConfig|dict): Configuration to update.
"""
@rpc_call
def add_ring(self, **kwargs) -> "Ring":
"""
Add a new progress bar.
Args:
**kwargs: Keyword arguments for the new progress bar.
Returns:
Ring: Ring object.
"""
@rpc_call
def remove_ring(self, index: "int"):
"""
Remove a progress bar by index.
Args:
index(int): Index of the progress bar to remove.
"""
@rpc_call
def set_precision(self, precision: "int", bar_index: "int | None" = None):
"""
Set the precision for the progress bars. If bar_index is not provide, the precision will be set for all progress bars.
Args:
precision(int): Precision for the progress bars.
bar_index(int): Index of the progress bar to set the precision for. If provided, only a single precision can be set.
"""
@rpc_call
def set_min_max_values(
self,
min_values: "int | float | list[int | float]",
max_values: "int | float | list[int | float]",
):
"""
Set the minimum and maximum values for the progress bars.
Args:
min_values(int|float | list[float]): Minimum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of minimum values for each progress bar.
max_values(int|float | list[float]): Maximum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of maximum values for each progress bar.
"""
@rpc_call
def set_number_of_bars(self, num_bars: "int"):
"""
Set the number of progress bars to display.
Args:
num_bars(int): Number of progress bars to display.
"""
@rpc_call
def set_value(self, values: "int | list", ring_index: "int" = None):
"""
Set the values for the progress bars.
Args:
values(int | tuple): Value(s) for the progress bars. If multiple progress bars are displayed, provide a tuple of values for each progress bar.
ring_index(int): Index of the progress bar to set the value for. If provided, only a single value can be set.
Examples:
>>> SpiralProgressBar.set_value(50)
>>> SpiralProgressBar.set_value([30, 40, 50]) # (outer, middle, inner)
>>> SpiralProgressBar.set_value(60, bar_index=1) # Set the value for the middle progress bar.
"""
@rpc_call
def set_colors_from_map(self, colormap, color_format: "Literal['RGB', 'HEX']" = "RGB"):
"""
Set the colors for the progress bars from a colormap.
Args:
colormap(str): Name of the colormap.
color_format(Literal["RGB","HEX"]): Format of the returned colors ('RGB', 'HEX').
"""
@rpc_call
def set_colors_directly(
self, colors: "list[str | tuple] | str | tuple", bar_index: "int" = None
):
"""
Set the colors for the progress bars directly.
Args:
colors(list[str | tuple] | str | tuple): Color(s) for the progress bars. If multiple progress bars are displayed, provide a list of colors for each progress bar.
bar_index(int): Index of the progress bar to set the color for. If provided, only a single color can be set.
"""
@rpc_call
def set_line_widths(self, widths: "int | list[int]", bar_index: "int" = None):
"""
Set the line widths for the progress bars.
Args:
widths(int | list[int]): Line width(s) for the progress bars. If multiple progress bars are displayed, provide a list of line widths for each progress bar.
bar_index(int): Index of the progress bar to set the line width for. If provided, only a single line width can be set.
"""
@rpc_call
def set_gap(self, gap: "int"):
"""
Set the gap between the progress bars.
Args:
gap(int): Gap between the progress bars.
"""
@rpc_call
def set_diameter(self, diameter: "int"):
"""
Set the diameter of the widget.
Args:
diameter(int): Diameter of the widget.
"""
@rpc_call
def reset_diameter(self):
"""
Reset the fixed size of the widget.
"""
@rpc_call
def enable_auto_updates(self, enable: "bool" = True):
"""
Enable or disable updates based on scan status. Overrides manual updates.
The behaviour of the whole progress bar widget will be driven by the scan queue status.
Args:
enable(bool): True or False.
Returns:
bool: True if scan segment updates are enabled.
"""
@rpc_call
@@ -4784,56 +4989,6 @@ class RingProgressBar(RPCBase):
Take a screenshot of the dock area and save it to a file.
"""
@property
@rpc_call
def rings(self) -> list[bec_widgets.widgets.progress.ring_progress_bar.ring.Ring]:
"""
None
"""
@rpc_call
def add_ring(
self, config: dict | None = None
) -> bec_widgets.widgets.progress.ring_progress_bar.ring.Ring:
"""
Add a new ring to the ring progress bar.
Optionally, a configuration dictionary can be provided but the ring
can also be configured later. The config dictionary must provide
the qproperties of the Qt Ring object.
Args:
config(dict | None): Optional configuration dictionary for the ring.
Returns:
Ring: The newly added ring object.
"""
@rpc_call
def remove_ring(self, index: int | None = None):
"""
Remove a ring from the ring progress bar.
Args:
index(int | None): Index of the ring to remove. If None, removes the last ring.
"""
@rpc_call
def set_gap(self, value: int):
"""
Set the gap between rings.
Args:
value(int): Gap value in pixels.
"""
@rpc_call
def set_center_label(self, text: str):
"""
Set the center label text.
Args:
text(str): Text for the center label.
"""
class SBBMonitor(RPCBase):
"""A widget to display the SBB monitor website."""
@@ -5364,6 +5519,47 @@ class ScatterWaveform(RPCBase):
"""
class SignalComboBox(RPCBase):
"""Line edit widget for device input with autocomplete for device names."""
@rpc_call
def set_signal(self, signal: str):
"""
Set the signal.
Args:
signal (str): signal name.
"""
@rpc_call
def set_device(self, device: str | None):
"""
Set the device. If device is not valid, device will be set to None which happens
Args:
device(str): device name.
"""
@property
@rpc_call
def signals(self) -> list[str]:
"""
Get the list of device signals for the applied filters.
Returns:
list[str]: List of device signals.
"""
@rpc_call
def get_signal_name(self) -> "str":
"""
Get the signal name from the combobox.
Returns:
str: The signal name.
"""
class SignalLabel(RPCBase):
@property
@rpc_call
@@ -5506,6 +5702,48 @@ class SignalLabel(RPCBase):
"""
class SignalLineEdit(RPCBase):
"""Line edit widget for device input with autocomplete for device names."""
@property
@rpc_call
def _is_valid_input(self) -> bool:
"""
Check if the current value is a valid device name.
Returns:
bool: True if the current value is a valid device name, False otherwise.
"""
@rpc_call
def set_signal(self, signal: str):
"""
Set the signal.
Args:
signal (str): signal name.
"""
@rpc_call
def set_device(self, device: str | None):
"""
Set the device. If device is not valid, device will be set to None which happens
Args:
device(str): device name.
"""
@property
@rpc_call
def signals(self) -> list[str]:
"""
Get the list of device signals for the applied filters.
Returns:
list[str]: List of device signals.
"""
class TextBox(RPCBase):
"""A widget that displays text in plain and HTML format"""
@@ -5528,6 +5766,12 @@ class TextBox(RPCBase):
"""
class VSCodeEditor(RPCBase):
"""A widget to display the VSCode editor."""
...
class Waveform(RPCBase):
"""Widget for plotting waveforms."""

View File

@@ -5,13 +5,14 @@ from threading import RLock
from typing import TYPE_CHECKING, Callable
from weakref import WeakValueDictionary
import shiboken6 as shb
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
logger = bec_logger.logger
@@ -108,19 +109,11 @@ class RPCRegister:
dict: A dictionary containing all the registered RPC objects.
"""
with self._lock:
connections = {}
for gui_id, obj in self._rpc_register.items():
try:
if not shb.isValid(obj):
continue
connections[gui_id] = obj
except Exception as e:
logger.warning(f"Error checking validity of object {gui_id}: {e}")
continue
connections = dict(self._rpc_register)
return connections
def get_names_of_rpc_by_class_type(
self, cls: type[BECWidget] | type[BECConnector]
self, cls: type[BECWidget] | type[BECConnector] | type[BECDock] | type[BECDockArea]
) -> list[str]:
"""Get all the names of the widgets.

View File

@@ -25,16 +25,6 @@ class FakeDevice(BECDevice):
"readOnly": False,
"name": self.name,
}
self._info = {
"signals": {
self.name: {
"kind_str": "hinted",
"component_name": self.name,
"obj_name": self.name,
"signal_class": "Signal",
}
}
}
@property
def readout_priority(self):

View File

@@ -8,11 +8,10 @@ import uuid
from datetime import datetime
from typing import TYPE_CHECKING, Optional
import shiboken6 as shb
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, QTimer, Signal
from qtpy.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
@@ -186,7 +185,7 @@ class BECConnector:
# If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
self.root_widget = root_widget
self._update_object_name()
QTimer.singleShot(0, self._update_object_name)
@property
def parent_id(self) -> str | None:
@@ -207,7 +206,7 @@ class BECConnector:
"""
self.rpc_register.remove_rpc(self)
self.setObjectName(name.replace("-", "_").replace(" ", "_"))
self._update_object_name()
QTimer.singleShot(0, self._update_object_name)
def _update_object_name(self) -> None:
"""
@@ -220,8 +219,7 @@ class BECConnector:
self.rpc_register.add_rpc(self)
try:
self.name_established.emit(self.object_name)
except RuntimeError as e:
logger.warning(f"Error emitting name_established signal: {e}")
except RuntimeError:
return
def _enforce_unique_sibling_name(self):
@@ -232,20 +230,23 @@ class BECConnector:
- If there's a nearest BECConnector parent, only compare with children of that parent.
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
"""
if not shb.isValid(self):
return
QApplication.sendPostedEvents()
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
if parent_bec:
# We have a parent => only compare with siblings under that parent
siblings = [sib for sib in parent_bec.findChildren(BECConnector) if shb.isValid(sib)]
siblings = parent_bec.findChildren(BECConnector)
else:
# No parent => treat all top-level BECConnectors as siblings
# Use RPCRegister to avoid QApplication.allWidgets() during event processing.
connections = self.rpc_register.list_all_connections().values()
all_bec = [w for w in connections if isinstance(w, BECConnector) and shb.isValid(w)]
siblings = [w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None]
# 1) Gather all BECConnectors from QApplication
all_widgets = QApplication.allWidgets()
all_bec = [w for w in all_widgets if isinstance(w, BECConnector)]
# 2) "Top-level" means closest BECConnector parent is None
top_level_bec = [
w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None
]
# 3) We are among these top-level siblings
siblings = top_level_bec
# Collect used names among siblings
used_names = {sib.objectName() for sib in siblings if sib is not self}
@@ -480,62 +481,6 @@ class BECConnector:
else:
return self.config
def export_settings(self) -> dict:
"""
Export the settings of the widget as dict.
Returns:
dict: The exported settings of the widget.
"""
# We first get all qproperties that were defined in a bec_widgets class
objs = self._get_bec_meta_objects()
settings = {}
for prop_name in objs.keys():
try:
prop_value = getattr(self, prop_name)
settings[prop_name] = prop_value
except Exception as e:
logger.warning(
f"Could not export property '{prop_name}' from '{self.__class__.__name__}': {e}"
)
return settings
def load_settings(self, settings: dict) -> None:
"""
Load the settings of the widget from dict.
Args:
settings (dict): The settings to load into the widget.
"""
objs = self._get_bec_meta_objects()
for prop_name, prop_value in settings.items():
if prop_name in objs:
try:
setattr(self, prop_name, prop_value)
except Exception as e:
logger.warning(
f"Could not load property '{prop_name}' into '{self.__class__.__name__}': {e}"
)
def _get_bec_meta_objects(self) -> dict:
"""
Get BEC meta objects for the widget.
Returns:
dict: BEC meta objects.
"""
if not isinstance(self, QObject):
return {}
objects = {}
for name, attr in vars(self.__class__).items():
if isinstance(attr, Property):
# Check if the property is a SafeProperty
is_safe_property = getattr(attr.fget, "__is_safe_getter__", False)
if is_safe_property:
objects[name] = attr
return objects
# --- Example usage of BECConnector: running a simple task ---
if __name__ == "__main__": # pragma: no cover

View File

@@ -6,20 +6,17 @@ from typing import TYPE_CHECKING
import shiboken6
from bec_lib.logger import bec_logger
from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt
from qtpy.QtGui import QFont, QPixmap
from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.busy_loader import install_busy_loader
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
from bec_widgets.utils.rpc_decorator import rpc_timeout
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.busy_loader import BusyLoaderOverlay
from bec_widgets.widgets.containers.dock import BECDock
logger = bec_logger.logger
@@ -41,6 +38,7 @@ class BECWidget(BECConnector):
gui_id: str | None = None,
theme_update: bool = False,
start_busy: bool = False,
busy_text: str = "Loading…",
**kwargs,
):
"""
@@ -67,14 +65,18 @@ class BECWidget(BECConnector):
self._connect_to_theme_change()
# Initialize optional busy loader overlay utility (lazy by default)
self._busy_overlay: "BusyLoaderOverlay" | None = None
self._busy_state_widget: QWidget | None = None
self._busy_overlay = None
self._loading = False
self._busy_overlay = self._install_busy_loader()
if start_busy and isinstance(self, QWidget):
self._show_busy_overlay()
self._loading = True
try:
overlay = self._ensure_busy_overlay(busy_text=busy_text)
if overlay is not None:
overlay.setGeometry(self.rect())
overlay.raise_()
overlay.show()
self._loading = True
except Exception as exc:
logger.debug(f"Busy loader init skipped: {exc}")
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
@@ -95,109 +97,48 @@ class BECWidget(BECConnector):
self._update_overlay_theme(theme)
self.apply_theme(theme)
def create_busy_state_widget(self) -> QWidget:
"""
Method to create a custom busy state widget to be shown in the busy overlay.
Child classes should overrid this method to provide a custom widget if desired.
Returns:
QWidget: The custom busy state widget.
NOTE:
The implementation here is a SpinnerWidget with a "Loading..." label. This is the default
busy state widget for all BECWidgets. However, child classes with specific needs for the
busy state can easily overrite this method to provide a custom widget. The signature of
the method must be preserved to ensure compatibility with the busy overlay system. If
the widget provides a 'cleanup' method, it will be called when the overlay is cleaned up.
The widget may connect to the _busy_overlay signals foreground_color_changed and
scrim_color_changed to update its colors when the theme changes.
"""
# Widget
class BusyStateWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# label
label = QLabel("Loading...", self)
label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
f = QFont(label.font())
f.setBold(True)
f.setPointSize(f.pointSize() + 1)
label.setFont(f)
# spinner
spinner = SpinnerWidget(self)
spinner.setFixedSize(42, 42)
# Layout
lay = QVBoxLayout(self)
lay.setContentsMargins(24, 24, 24, 24)
lay.setSpacing(10)
lay.addStretch(1)
lay.addWidget(spinner, 0, Qt.AlignHCenter)
lay.addWidget(label, 0, Qt.AlignHCenter)
lay.addStretch(1)
self.setLayout(lay)
def showEvent(self, event):
"""Show event to start the spinner."""
super().showEvent(event)
for child in self.findChildren(SpinnerWidget):
child.start()
def hideEvent(self, event):
"""Hide event to stop the spinner."""
super().hideEvent(event)
for child in self.findChildren(SpinnerWidget):
child.stop()
widget = BusyStateWidget(self)
return widget
def _install_busy_loader(self) -> "BusyLoaderOverlay" | None:
"""
Create the busy overlay on demand and cache it in _busy_overlay.
def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"):
"""Create the busy overlay on demand and cache it in _busy_overlay.
Returns the overlay instance or None if not a QWidget.
"""
if not isinstance(self, QWidget):
return None
overlay = getattr(self, "_busy_overlay", None)
if overlay is None:
from bec_widgets.utils.busy_loader import install_busy_loader
overlay = install_busy_loader(target=self, start_loading=False)
overlay = install_busy_loader(self, text=busy_text, start_loading=False)
self._busy_overlay = overlay
# Create and set the busy state widget
self._busy_state_widget = self.create_busy_state_widget()
self._busy_overlay.set_widget(self._busy_state_widget)
return overlay
def _show_busy_overlay(self) -> None:
def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> None:
"""Create and attach the loading overlay to this widget if QWidget is present."""
if not isinstance(self, QWidget):
return
if self._busy_overlay is not None:
self._busy_overlay.setGeometry(self.rect()) # pylint: disable=no-member
self._ensure_busy_overlay(busy_text=busy_text)
if start_busy and self._busy_overlay is not None:
self._busy_overlay.setGeometry(self.rect())
self._busy_overlay.raise_()
self._busy_overlay.show()
def set_busy(self, enabled: bool) -> None:
def set_busy(self, enabled: bool, text: str | None = None) -> None:
"""
Set the busy state of the widget. This will show or hide the loading overlay, which will
block user interaction with the widget and show the busy_state_widget if provided. Per
default, the busy state widget is a spinner with "Loading..." text.
Enable/disable the loading overlay. Optionally update the text.
Args:
enabled(bool): Whether to enable the busy state.
enabled(bool): Whether to enable the loading overlay.
text(str, optional): The text to display on the overlay. If None, the text is not changed.
"""
if not isinstance(self, QWidget):
return
# If not yet installed, install the busy overlay now together with the busy state widget
if self._busy_overlay is None:
self._busy_overlay = self._install_busy_loader()
if getattr(self, "_busy_overlay", None) is None:
self._ensure_busy_overlay(busy_text=text or "Loading…")
if text is not None:
self.set_busy_text(text)
if enabled:
self._show_busy_overlay()
self._busy_overlay.setGeometry(self.rect())
self._busy_overlay.raise_()
self._busy_overlay.show()
else:
self._busy_overlay.hide()
self._loading = bool(enabled)
@@ -211,6 +152,19 @@ class BECWidget(BECConnector):
"""
return bool(getattr(self, "_loading", False))
def set_busy_text(self, text: str) -> None:
"""
Update the text on the loading overlay.
Args:
text(str): The text to display on the overlay.
"""
overlay = getattr(self, "_busy_overlay", None)
if overlay is None:
overlay = self._ensure_busy_overlay(busy_text=text)
if overlay is not None:
overlay.set_text(text)
@SafeSlot(str)
def apply_theme(self, theme: str):
"""
@@ -223,8 +177,8 @@ class BECWidget(BECConnector):
def _update_overlay_theme(self, theme: str):
try:
overlay = getattr(self, "_busy_overlay", None)
if overlay is not None:
overlay._update_palette()
if overlay is not None and hasattr(overlay, "update_palette"):
overlay.update_palette()
except Exception:
logger.warning(f"Failed to apply theme {theme} to {self}")
@@ -350,13 +304,10 @@ class BECWidget(BECConnector):
self.removeEventFilter(filt)
except Exception as exc:
logger.warning(f"Failed to remove event filter from busy overlay: {exc}")
# Cleanup the overlay widget. This will call cleanup on the custom widget if present.
overlay.cleanup()
overlay.deleteLater()
except Exception as exc:
logger.warning(f"Failed to delete busy overlay: {exc}")
self._busy_overlay = None
def closeEvent(self, event):
"""Wrap the close even to ensure the rpc_register is cleaned up."""

View File

@@ -1,8 +1,7 @@
from __future__ import annotations
from bec_lib.logger import bec_logger
from qtpy.QtCore import QEvent, QObject, Qt, QTimer, Signal
from qtpy.QtGui import QColor
from qtpy.QtCore import QEvent, QObject, Qt, QTimer
from qtpy.QtGui import QColor, QFont
from qtpy.QtWidgets import (
QApplication,
QFrame,
@@ -14,10 +13,10 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeProperty
logger = bec_logger.logger
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
class _OverlayEventFilter(QObject):
@@ -29,10 +28,6 @@ class _OverlayEventFilter(QObject):
self._overlay = overlay
def eventFilter(self, obj, event):
if not hasattr(self, "_target") or self._target is None:
return False
if not hasattr(self, "_overlay") or self._overlay is None:
return False
if obj is self._target and event.type() in (
QEvent.Resize,
QEvent.Show,
@@ -58,201 +53,132 @@ class BusyLoaderOverlay(QWidget):
BusyLoaderOverlay: The overlay instance.
"""
foreground_color_changed = Signal(QColor)
scrim_color_changed = Signal(QColor)
def __init__(self, parent: QWidget, opacity: float = 0.35, **kwargs):
def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **kwargs):
super().__init__(parent=parent, **kwargs)
self.setAttribute(Qt.WA_StyledBackground, True)
self.setAutoFillBackground(False)
self.setAttribute(Qt.WA_TranslucentBackground, True)
self._opacity = opacity
self._scrim_color = QColor(128, 128, 128, 110)
self._label_color = QColor(240, 240, 240)
self._filter: QObject | None = None
# Set Main Layout
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(10)
self.setLayout(layout)
self._label = QLabel(text, self)
self._label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
f = QFont(self._label.font())
f.setBold(True)
f.setPointSize(f.pointSize() + 1)
self._label.setFont(f)
# Custom widget placeholder
self._custom_widget: QWidget | None = None
self._spinner = SpinnerWidget(self)
self._spinner.setFixedSize(42, 42)
lay = QVBoxLayout(self)
lay.setContentsMargins(24, 24, 24, 24)
lay.setSpacing(10)
lay.addStretch(1)
lay.addWidget(self._spinner, 0, Qt.AlignHCenter)
lay.addWidget(self._label, 0, Qt.AlignHCenter)
lay.addStretch(1)
# Add a frame around the content
self._frame = QFrame(self)
self._frame.setObjectName("busyFrame")
self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True)
self._frame.lower()
# Defaults
self._update_palette()
self._scrim_color = QColor(0, 0, 0, 110)
self._label_color = QColor(240, 240, 240)
self.update_palette()
# Start hidden; interactions beneath are blocked while visible
self.hide()
@SafeProperty(QColor, notify=scrim_color_changed)
def scrim_color(self) -> QColor:
# --- API ---
def set_text(self, text: str):
"""
The overlay scrim color.
"""
return self._scrim_color
@scrim_color.setter
def scrim_color(self, value: QColor):
if not isinstance(value, QColor):
raise TypeError("scrim_color must be a QColor")
self._scrim_color = value
self.update()
@SafeProperty(QColor, notify=foreground_color_changed)
def foreground_color(self) -> QColor:
"""
The overlay foreground color (text, spinner).
"""
return self._label_color
@foreground_color.setter
def foreground_color(self, value: QColor):
if not isinstance(value, QColor):
try:
color = QColor(value)
if not color.isValid():
raise ValueError(f"Invalid color: {value}")
except Exception:
# pylint: disable=raise-missing-from
raise ValueError(f"Color {value} is invalid, cannot be converted to QColor")
self._label_color = value
self.update()
def set_filter(self, filt: _OverlayEventFilter):
"""
Set an event filter to keep the overlay sized and stacked over its target.
Update the overlay text.
Args:
filt(QObject): The event filter instance.
text(str): The text to display on the overlay.
"""
self._filter = filt
target = filt._target
if self.parent() != target:
logger.warning(f"Overlay parent {self.parent()} does not match filter target {target}")
target.installEventFilter(self._filter)
######################
### Public methods ###
######################
def set_widget(self, widget: QWidget):
"""
Set a custom widget as an overlay for the busy overlay.
Args:
widget(QWidget): The custom widget to display.
"""
lay = self.layout()
if lay is None:
return
self._custom_widget = widget
lay.addWidget(widget, 0, Qt.AlignHCenter)
self._label.setText(text)
def set_opacity(self, opacity: float):
"""
Set the overlay opacity. Only values between 0.0 and 1.0 are accepted. If a
value outside this range is provided, it will be clamped.
Set overlay opacity (0..1).
Args:
opacity(float): The opacity value between 0.0 (fully transparent) and 1.0 (fully opaque).
"""
self._opacity = max(0.0, min(1.0, float(opacity)))
# Re-apply alpha using the current theme color
base = self.scrim_color
base.setAlpha(int(255 * self._opacity))
self.scrim_color = base
self._update_palette()
if isinstance(self._scrim_color, QColor):
base = QColor(self._scrim_color)
base.setAlpha(int(255 * self._opacity))
self._scrim_color = base
self.update()
##########################
### Internal methods ###
##########################
def _update_palette(self):
def update_palette(self):
"""
Update colors from the current application theme.
"""
_app = QApplication.instance()
if hasattr(_app, "theme"):
theme = _app.theme # type: ignore[attr-defined]
_bg = theme.color("BORDER")
_fg = theme.color("FG")
app = QApplication.instance()
if hasattr(app, "theme"):
theme = app.theme # type: ignore[attr-defined]
self._bg = theme.color("BORDER")
self._fg = theme.color("FG")
self._primary = theme.color("PRIMARY")
else:
# Fallback neutrals
_bg = QColor(30, 30, 30)
_fg = QColor(230, 230, 230)
self._bg = QColor(30, 30, 30)
self._fg = QColor(230, 230, 230)
# Semi-transparent scrim derived from bg
base = _bg if isinstance(_bg, QColor) else QColor(str(_bg))
base.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35)))))
self.scrim_color = base
fg = _fg if isinstance(_fg, QColor) else QColor(str(_fg))
self.foreground_color = fg
# Set the frame style with updated foreground colors
r, g, b, a = base.getRgb()
self._scrim_color = QColor(self._bg)
self._scrim_color.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35)))))
self._spinner.update()
fg_hex = self._fg.name() if isinstance(self._fg, QColor) else str(self._fg)
self._label.setStyleSheet(f"color: {fg_hex};")
self._frame.setStyleSheet(
f"#busyFrame {{ border: 2px dashed {self.foreground_color.name()}; border-radius: 9px; background-color: rgba({r}, {g}, {b}, {a}); }}"
f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}"
)
self.update()
#############################
### Custom Event Handlers ###
#############################
# --- QWidget overrides ---
def showEvent(self, e):
# Call showEvent on custom widget if present
if self._custom_widget is not None:
self._custom_widget.showEvent(e)
self._spinner.start()
super().showEvent(e)
def hideEvent(self, e):
# Call hideEvent on custom widget if present
if self._custom_widget is not None:
self._custom_widget.hideEvent(e)
self._spinner.stop()
super().hideEvent(e)
def resizeEvent(self, e):
# Call resizeEvent on custom widget if present
if self._custom_widget is not None:
self._custom_widget.resizeEvent(e)
super().resizeEvent(e)
r = self.rect().adjusted(10, 10, -10, -10)
self._frame.setGeometry(r)
# TODO should we have this cleanup here?
def cleanup(self):
"""Cleanup resources used by the overlay."""
if self._custom_widget is not None:
if hasattr(self._custom_widget, "cleanup"):
self._custom_widget.cleanup()
def paintEvent(self, e):
super().paintEvent(e)
def install_busy_loader(
target: QWidget, start_loading: bool = False, opacity: float = 0.35
target: QWidget, text: str = "Loading…", start_loading: bool = False, opacity: float = 0.35
) -> BusyLoaderOverlay:
"""
Attach a BusyLoaderOverlay to `target` and keep it sized and stacked.
Args:
target(QWidget): The widget to overlay.
text(str): Initial text to display.
start_loading(bool): If True, show the overlay immediately.
opacity(float): Overlay opacity (0..1).
Returns:
BusyLoaderOverlay: The overlay instance.
"""
overlay = BusyLoaderOverlay(parent=target, opacity=opacity)
overlay = BusyLoaderOverlay(target, text=text, opacity=opacity)
overlay.setGeometry(target.rect())
overlay.set_filter(_OverlayEventFilter(target=target, overlay=overlay))
filt = _OverlayEventFilter(target, overlay)
overlay._filter = filt # type: ignore[attr-defined]
target.installEventFilter(filt)
if start_loading:
overlay.show()
return overlay
@@ -261,63 +187,65 @@ def install_busy_loader(
# --------------------------
# Launchable demo
# --------------------------
class DemoWidget(BECWidget, QWidget): # pragma: no cover
def __init__(self, parent=None):
super().__init__(
parent=parent, theme_update=True, start_busy=True, busy_text="Demo: Initializing…"
)
self._title = QLabel("Demo Content", self)
self._title.setAlignment(Qt.AlignCenter)
self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken)
lay = QVBoxLayout(self)
lay.addWidget(self._title)
waveform = Waveform(self)
waveform.plot([1, 2, 3, 4, 5])
lay.addWidget(waveform, 1)
QTimer.singleShot(5000, self._ready)
def _ready(self):
self._title.setText("Ready ✓")
self.set_busy(False)
class DemoWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Busy Loader — BECWidget demo")
left = DemoWidget()
right = DemoWidget()
btn_on = QPushButton("Right → Loading")
btn_off = QPushButton("Right → Ready")
btn_text = QPushButton("Set custom text")
btn_on.clicked.connect(lambda: right.set_busy(True, "Fetching data…"))
btn_off.clicked.connect(lambda: right.set_busy(False))
btn_text.clicked.connect(lambda: right.set_busy_text("Almost there…"))
panel = QWidget()
prow = QVBoxLayout(panel)
prow.addWidget(btn_on)
prow.addWidget(btn_off)
prow.addWidget(btn_text)
prow.addStretch(1)
central = QWidget()
row = QHBoxLayout(central)
row.setContentsMargins(12, 12, 12, 12)
row.setSpacing(12)
row.addWidget(left, 1)
row.addWidget(right, 1)
row.addWidget(panel, 0)
self.setCentralWidget(central)
self.resize(900, 420)
if __name__ == "__main__": # pragma: no cover
import sys
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.plots.waveform.waveform import Waveform
class DemoWidget(BECWidget, QWidget): # pragma: no cover
def __init__(self, parent=None, start_busy: bool = False):
super().__init__(parent=parent, theme_update=True, start_busy=start_busy)
self._title = QLabel("Demo Content", self)
self._title.setAlignment(Qt.AlignCenter)
self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken)
lay = QVBoxLayout(self)
lay.addWidget(self._title)
waveform = Waveform(self)
waveform.plot([1, 2, 3, 4, 5])
lay.addWidget(waveform, 1)
QTimer.singleShot(5000, self._ready)
def _ready(self):
self._title.setText("Ready ✓")
self.set_busy(False)
class DemoWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Busy Loader — BECWidget demo")
left = DemoWidget(start_busy=True)
right = DemoWidget()
btn_on = QPushButton("Right → Loading")
btn_off = QPushButton("Right → Ready")
btn_text = QPushButton("Set custom text")
btn_on.clicked.connect(lambda: right.set_busy(True))
btn_off.clicked.connect(lambda: right.set_busy(False))
panel = QWidget()
prow = QVBoxLayout(panel)
prow.addWidget(btn_on)
prow.addWidget(btn_off)
prow.addWidget(btn_text)
prow.addStretch(1)
central = QWidget()
row = QHBoxLayout(central)
row.setContentsMargins(12, 12, 12, 12)
row.setSpacing(12)
row.addWidget(left, 1)
row.addWidget(right, 1)
row.addWidget(panel, 0)
self.setCentralWidget(central)
self.resize(900, 420)
app = QApplication(sys.argv)
apply_theme("light")
w = DemoWindow()

View File

@@ -1,22 +1,17 @@
from __future__ import annotations
import re
from functools import lru_cache
from typing import Literal
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger
from bec_qthemes import apply_theme as apply_theme_global
from bec_qthemes._theme import AccentColors
from pydantic_core import PydanticCustomError
from pyqtgraph.graphicsItems.GradientEditorItem import Gradients
from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
logger = bec_logger.logger
def get_theme_name():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
@@ -52,103 +47,12 @@ def apply_theme(theme: Literal["dark", "light"]):
"""
Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally.
"""
logger.info(f"Applying theme: {theme}")
process_all_deferred_deletes(QApplication.instance())
apply_theme_global(theme)
process_all_deferred_deletes(QApplication.instance())
class Colors:
@staticmethod
def list_available_colormaps() -> list[str]:
"""
List colormap names available via the pyqtgraph colormap registry.
Note: This does not include `GradientEditorItem` presets (used by HistogramLUT menus).
"""
def _list(source: str | None = None) -> list[str]:
try:
return pg.colormap.listMaps() if source is None else pg.colormap.listMaps(source)
except Exception: # pragma: no cover - backend may be missing
return []
return [*_list(None), *_list("matplotlib"), *_list("colorcet")]
@staticmethod
def list_available_gradient_presets() -> list[str]:
"""
List `GradientEditorItem` preset names (HistogramLUT right-click menu entries).
"""
from pyqtgraph.graphicsItems.GradientEditorItem import Gradients
return list(Gradients.keys())
@staticmethod
def canonical_colormap_name(color_map: str) -> str:
"""
Return an available colormap/preset name if a case-insensitive match exists.
"""
requested = (color_map or "").strip()
if not requested:
return requested
registry = Colors.list_available_colormaps()
presets = Colors.list_available_gradient_presets()
available = set(registry) | set(presets)
if requested in available:
return requested
# Case-insensitive match.
requested_lc = requested.casefold()
for name in available:
if name.casefold() == requested_lc:
return name
return requested
@staticmethod
def get_colormap(color_map: str) -> pg.ColorMap:
"""
Resolve a string into a `pg.ColorMap` using either:
- the `pg.colormap` registry (optionally including matplotlib/colorcet backends), or
- `GradientEditorItem` presets (HistogramLUT right-click menu).
"""
name = Colors.canonical_colormap_name(color_map)
if not name:
raise ValueError("Empty colormap name")
return Colors._get_colormap_cached(name)
@staticmethod
@lru_cache(maxsize=256)
def _get_colormap_cached(name: str) -> pg.ColorMap:
# 1) Registry/backends
try:
cmap = pg.colormap.get(name)
if cmap is not None:
return cmap
except Exception:
pass
for source in ("matplotlib", "colorcet"):
try:
cmap = pg.colormap.get(name, source=source)
if cmap is not None:
return cmap
except Exception:
continue
# 2) Presets -> ColorMap
if name not in Gradients:
raise KeyError(f"Colormap '{name}' not found")
ge = pg.GradientEditorItem()
ge.loadPreset(name)
return ge.colorMap()
@staticmethod
def golden_ratio(num: int) -> list:
@@ -230,7 +134,7 @@ class Colors:
if theme_offset < 0 or theme_offset > 1:
raise ValueError("theme_offset must be between 0 and 1")
cmap = Colors.get_colormap(colormap)
cmap = pg.colormap.get(colormap)
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
# Generate positions that are evenly spaced within the acceptable range
@@ -278,7 +182,7 @@ class Colors:
ValueError: If theme_offset is not between 0 and 1.
"""
cmap = Colors.get_colormap(colormap)
cmap = pg.colormap.get(colormap)
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125
@@ -544,103 +448,18 @@ class Colors:
Raises:
PydanticCustomError: If colormap is invalid.
"""
normalized = Colors.canonical_colormap_name(color_map)
try:
Colors.get_colormap(normalized)
except Exception as ext:
logger.warning(f"Colormap validation error: {ext}")
available_pg_maps = pg.colormap.listMaps()
available_mpl_maps = pg.colormap.listMaps("matplotlib")
available_mpl_colorcet = pg.colormap.listMaps("colorcet")
available_colormaps = available_pg_maps + available_mpl_maps + available_mpl_colorcet
if color_map not in available_colormaps:
if return_error:
available_colormaps = sorted(
set(Colors.list_available_colormaps())
| set(Colors.list_available_gradient_presets())
)
raise PydanticCustomError(
"unsupported colormap",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose from the following: {available_colormaps}.",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
{"wrong_value": color_map},
)
else:
return False
return normalized
@staticmethod
def relative_luminance(color: QColor) -> float:
"""
Calculate the relative luminance of a QColor according to WCAG 2.0 standards.
See https://www.w3.org/TR/WCAG21/#dfn-relative-luminance.
Args:
color(QColor): The color to calculate the relative luminance for.
Returns:
float: The relative luminance of the color.
"""
r = color.red() / 255.0
g = color.green() / 255.0
b = color.blue() / 255.0
def adjust(c):
if c <= 0.03928:
return c / 12.92
return ((c + 0.055) / 1.055) ** 2.4
r = adjust(r)
g = adjust(g)
b = adjust(b)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
@staticmethod
def _tint_strength(
accent: QColor, background: QColor, min_tint: float = 0.06, max_tint: float = 0.18
) -> float:
"""
Calculate the tint strength based on the contrast between the accent and background colors.
min_tint and max_tint define the range of tint strength and are empirically chosen.
Args:
accent(QColor): The accent color.
background(QColor): The background color.
min_tint(float): The minimum tint strength.
max_tint(float): The maximum tint strength.
Returns:
float: The tint strength between 0 and 1.
"""
l_accent = Colors.relative_luminance(accent)
l_bg = Colors.relative_luminance(background)
contrast = abs(l_accent - l_bg)
# normalize contrast to a value between 0 and 1
t = min(contrast / 0.9, 1.0)
return min_tint + t * (max_tint - min_tint)
@staticmethod
def _blend(background: QColor, accent: QColor, t: float) -> QColor:
"""
Blend two colors based on a tint strength t.
"""
return QColor(
round(background.red() + (accent.red() - background.red()) * t),
round(background.green() + (accent.green() - background.green()) * t),
round(background.blue() + (accent.blue() - background.blue()) * t),
round(background.alpha() + (accent.alpha() - background.alpha()) * t),
)
@staticmethod
def subtle_background_color(accent: QColor, background: QColor) -> QColor:
"""
Generate a subtle, contrast-safe background color derived from an accent color.
Args:
accent(QColor): The accent color.
background(QColor): The background color.
Returns:
QColor: The generated subtle background color.
"""
if not accent.isValid() or not background.isValid():
return background
tint = Colors._tint_strength(accent, background)
return Colors._blend(background, accent, tint)
return color_map

View File

@@ -1,38 +1,19 @@
import functools
import sys
import traceback
from typing import Any, Callable, Literal
import shiboken6
from bec_lib.logger import bec_logger
from louie.saferef import safe_ref
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
from qtpy.QtWidgets import (
QApplication,
QLabel,
QMessageBox,
QPushButton,
QSpinBox,
QTabWidget,
QVBoxLayout,
QWidget,
)
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
logger = bec_logger.logger
RAISE_ERROR_DEFAULT = False
def SafeProperty(
prop_type,
*prop_args,
popup_error: bool = False,
default: Any = None,
auto_emit: bool = False,
emit_value: Literal["stored", "input"] | Callable[[object, object], object] = "stored",
emit_on_change: bool = True,
**prop_kwargs,
):
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs):
"""
Decorator to create a Qt Property with safe getter and setter so that
Qt Designer won't crash if an exception occurs in either method.
@@ -41,15 +22,7 @@ def SafeProperty(
prop_type: The property type (e.g., str, bool, int, custom classes, etc.)
popup_error (bool): If True, show a popup for any error; otherwise, ignore or log silently.
default: Any default/fallback value to return if the getter raises an exception.
auto_emit (bool): If True, automatically emit property_changed signal when setter is called.
Requires the widget to have a property_changed signal (str, object).
Note: This is different from Qt's 'notify' parameter which expects a Signal.
emit_value: Controls which value is emitted when auto_emit=True.
- "stored" (default): emit the value from the getter after setter runs
- "input": emit the raw setter input
- callable: called as emit_value(self_, value) after setter and must return the value to emit
emit_on_change (bool): If True, emit only when the stored value changes.
*prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor (check https://doc.qt.io/qt-6/properties.html).
*prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor.
Usage:
@SafeProperty(int, default=-1)
@@ -61,41 +34,6 @@ def SafeProperty(
def some_value(self, val: int):
# your setter logic
...
# With auto-emit for toolbar sync:
@SafeProperty(bool, auto_emit=True)
def fft(self) -> bool:
return self._fft
@fft.setter
def fft(self, value: bool):
self._fft = value
# property_changed.emit("fft", value) is called automatically
# With custom emit modes:
@SafeProperty(int, auto_emit=True, emit_value="stored")
def precision_stored(self) -> int:
return self._precision_stored
@precision_stored.setter
def precision_stored(self, value: int):
self._precision_stored = max(0, int(value))
@SafeProperty(int, auto_emit=True, emit_value="input")
def precision_input(self) -> int:
return self._precision_input
@precision_input.setter
def precision_input(self, value: int):
self._precision_input = max(0, int(value))
@SafeProperty(int, auto_emit=True, emit_value=lambda _self, v: int(v) * 10)
def precision_callable(self) -> int:
return self._precision_callable
@precision_callable.setter
def precision_callable(self, value: int):
self._precision_callable = max(0, int(value))
"""
def decorator(py_getter):
@@ -115,8 +53,6 @@ def SafeProperty(
logger.error(f"SafeProperty error in GETTER of '{prop_name}':\n{error_msg}")
return default
safe_getter.__is_safe_getter__ = True # type: ignore[attr-defined]
class PropertyWrapper:
"""
Intermediate wrapper used so that the user can optionally chain .setter(...).
@@ -132,42 +68,8 @@ def SafeProperty(
@functools.wraps(setter_func)
def safe_setter(self_, value):
try:
before_value = None
if auto_emit and emit_on_change:
try:
before_value = self.getter_func(self_)
except Exception as e:
logger.warning(
f"SafeProperty could not get 'before' value for change detection: {e}"
)
before_value = None
result = setter_func(self_, value)
# Auto-emit property_changed if auto_emit=True and signal exists
if auto_emit and hasattr(self_, "property_changed"):
prop_name = py_getter.__name__
try:
if callable(emit_value):
emit_payload = emit_value(self_, value)
elif emit_value == "input":
emit_payload = value
else:
emit_payload = self.getter_func(self_)
if emit_on_change and before_value == emit_payload:
return result
self_.property_changed.emit(prop_name, emit_payload)
except Exception as notify_error:
# Don't fail the setter if notification fails
logger.warning(
f"SafeProperty auto_emit failed for '{prop_name}': {notify_error}"
)
return result
except Exception as e:
logger.warning(f"SafeProperty setter caught exception: {e}")
return setter_func(self_, value)
except Exception:
prop_name = f"{setter_func.__module__}.{setter_func.__qualname__}"
error_msg = traceback.format_exc()
@@ -433,100 +335,6 @@ def ErrorPopupUtility():
return _popup_utility_instance
class SafePropertyExampleWidget(QWidget): # pragma: no cover
"""
Example widget showcasing SafeProperty auto_emit modes.
"""
property_changed = Signal(str, object)
def __init__(self):
super().__init__()
self.setWindowTitle("SafeProperty auto_emit example")
self._precision_stored = 0
self._precision_input = 0
self._precision_callable = 0
layout = QVBoxLayout(self)
self.status = QLabel("last emit: <none>", self)
self.spinbox_stored = QSpinBox(self)
self.spinbox_stored.setRange(-5, 10)
self.spinbox_stored.setValue(0)
self.label_stored = QLabel("stored emit: <none>", self)
self.spinbox_input = QSpinBox(self)
self.spinbox_input.setRange(-5, 10)
self.spinbox_input.setValue(0)
self.label_input = QLabel("input emit: <none>", self)
self.spinbox_callable = QSpinBox(self)
self.spinbox_callable.setRange(-5, 10)
self.spinbox_callable.setValue(0)
self.label_callable = QLabel("callable emit: <none>", self)
layout.addWidget(QLabel("stored emit (normalized value):", self))
layout.addWidget(self.spinbox_stored)
layout.addWidget(self.label_stored)
layout.addWidget(QLabel("input emit (raw setter input):", self))
layout.addWidget(self.spinbox_input)
layout.addWidget(self.label_input)
layout.addWidget(QLabel("callable emit (custom mapping):", self))
layout.addWidget(self.spinbox_callable)
layout.addWidget(self.label_callable)
layout.addWidget(self.status)
self.spinbox_stored.valueChanged.connect(self._on_spinbox_stored)
self.spinbox_input.valueChanged.connect(self._on_spinbox_input)
self.spinbox_callable.valueChanged.connect(self._on_spinbox_callable)
self.property_changed.connect(self._on_property_changed)
@SafeProperty(int, auto_emit=True, emit_value="stored", doc="Clamped precision value.")
def precision_stored(self) -> int:
return self._precision_stored
@precision_stored.setter
def precision_stored(self, value: int):
self._precision_stored = max(0, int(value))
@SafeProperty(int, auto_emit=True, emit_value="input", doc="Emit raw input value.")
def precision_input(self) -> int:
return self._precision_input
@precision_input.setter
def precision_input(self, value: int):
self._precision_input = max(0, int(value))
@SafeProperty(int, auto_emit=True, emit_value=lambda _self, v: int(v) * 10)
def precision_callable(self) -> int:
return self._precision_callable
@precision_callable.setter
def precision_callable(self, value: int):
self._precision_callable = max(0, int(value))
def _on_spinbox_stored(self, value: int):
self.precision_stored = value
def _on_spinbox_input(self, value: int):
self.precision_input = value
def _on_spinbox_callable(self, value: int):
self.precision_callable = value
def _on_property_changed(self, prop_name: str, value):
self.status.setText(f"last emit: {prop_name}={value}")
if prop_name == "precision_stored":
self.label_stored.setText(f"stored emit: {value}")
elif prop_name == "precision_input":
self.label_input.setText(f"input emit: {value}")
elif prop_name == "precision_callable":
self.label_callable.setText(f"callable emit: {value}")
class ExampleWidget(QWidget): # pragma: no cover
"""
Example widget to demonstrate error handling with the ErrorPopupUtility.
@@ -581,10 +389,6 @@ class ExampleWidget(QWidget): # pragma: no cover
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
tabs = QTabWidget()
tabs.setWindowTitle("Error Popups & SafeProperty Examples")
tabs.addTab(ExampleWidget(), "Error Popups")
tabs.addTab(SafePropertyExampleWidget(), "SafeProperty auto_emit")
tabs.resize(420, 520)
tabs.show()
widget = ExampleWidget()
widget.show()
sys.exit(app.exec_())

View File

@@ -7,7 +7,6 @@ from abc import ABC, abstractmethod
from bec_lib.logger import bec_logger
from qtpy.QtCore import QStringListModel
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
from typeguard import TypeCheckError
from bec_widgets.utils.ophyd_kind_util import Kind
@@ -56,49 +55,6 @@ class WidgetFilterHandler(ABC):
"""
# This method should be implemented in subclasses or extended as needed
def update_with_bec_signal_class(
self,
signal_class_filter: str | list[str],
client,
ndim_filter: int | list[int] | None = None,
) -> list[tuple[str, str, dict]]:
"""Update the selection based on signal classes using device_manager.get_bec_signals.
Args:
signal_class_filter (str|list[str]): List of signal class names to filter.
client: BEC client instance.
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
If provided, only signals with matching ndim will be included.
Returns:
list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples.
"""
if not client or not hasattr(client, "device_manager"):
return []
try:
signals = client.device_manager.get_bec_signals(signal_class_filter)
except TypeCheckError as e:
logger.warning(f"Error retrieving signals: {e}")
return []
if ndim_filter is None:
return signals
if isinstance(ndim_filter, int):
ndim_filter = [ndim_filter]
filtered_signals = []
for device_name, signal_name, signal_config in signals:
ndim = None
if isinstance(signal_config, dict):
ndim = signal_config.get("describe", {}).get("signal_info", {}).get("ndim")
if ndim in ndim_filter:
filtered_signals.append((device_name, signal_name, signal_config))
return filtered_signals
class LineEditFilterHandler(WidgetFilterHandler):
"""Handler for QLineEdit widget"""
@@ -299,32 +255,6 @@ class FilterIO:
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
@staticmethod
def update_with_signal_class(
widget, signal_class_filter: list[str], client, ndim_filter: int | list[int] | None = None
) -> list[tuple[str, str, dict]]:
"""
Update the selection based on signal classes using device_manager.get_bec_signals.
Args:
widget: Widget instance.
signal_class_filter (list[str]): List of signal class names to filter.
client: BEC client instance.
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
If provided, only signals with matching ndim will be included.
Returns:
list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().update_with_bec_signal_class(
signal_class_filter=signal_class_filter, client=client, ndim_filter=ndim_filter
)
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
@staticmethod
def _find_handler(widget):
"""

View File

@@ -1,6 +1,5 @@
import os
import sys
from typing import Any
from PIL import Image, ImageChops
from qtpy.QtGui import QPixmap
@@ -41,7 +40,7 @@ def compare_images(image1_path: str, reference_image_path: str):
raise ValueError("Images are different")
def snap_and_compare(widget: Any, output_directory: str, suffix: str = ""):
def snap_and_compare(widget: any, output_directory: str, suffix: str = ""):
"""
Save a rendering of a widget and compare it to a reference image

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import functools
import time
import traceback
import types
from contextlib import contextmanager
@@ -11,6 +12,7 @@ from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QApplication
from redis.exceptions import RedisError
from bec_widgets.cli.rpc.rpc_register import RPCRegister
@@ -30,10 +32,6 @@ logger = bec_logger.logger
T = TypeVar("T")
class RegistryNotReadyError(Exception):
"""Raised when trying to access an object from the RPC registry that is not yet registered."""
@contextmanager
def rpc_exception_hook(err_func):
"""This context replaces the popup message box for error display with a specific hook"""
@@ -57,19 +55,6 @@ def rpc_exception_hook(err_func):
popup.custom_exception_hook = old_exception_hook
class SingleshotRPCRepeat:
def __init__(self, max_delay: int = 2000):
self.max_delay = max_delay
self.accumulated_delay = 0
def __iadd__(self, delay: int):
self.accumulated_delay += delay
if self.accumulated_delay > self.max_delay:
raise RegistryNotReadyError("Max delay exceeded for RPC singleshot repeat")
return self
class RPCServer:
client: BECClient
@@ -101,7 +86,6 @@ class RPCServer:
self._heartbeat_timer.start(200)
self._registry_update_callbacks = []
self._broadcasted_data = {}
self._rpc_singleshot_repeats: dict[str, SingleshotRPCRepeat] = {}
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
@@ -125,8 +109,7 @@ class RPCServer:
self.send_response(request_id, False, {"error": content})
else:
logger.debug(f"RPC instruction executed successfully: {res}")
self._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat()
QTimer.singleShot(0, lambda: self.serialize_result_and_send(request_id, res))
self.send_response(request_id, True, {"result": res})
def send_response(self, request_id: str, accepted: bool, msg: dict):
self.client.connector.set_and_publish(
@@ -184,61 +167,14 @@ class RPCServer:
res = None
else:
res = method_obj(*args, **kwargs)
return res
def serialize_result_and_send(self, request_id: str, res: object):
"""
Serialize the result of an RPC call and send it back to the client.
Note: If the object is not yet registered in the RPC registry, this method
will retry serialization after a short delay, up to a maximum delay. In order
to avoid processEvents calls in the middle of serialization, QTimer.singleShot is used.
This allows the target event to 'float' to the next event loop iteration until the
object is registered.
The 'jump' to the next event loop is indicated by raising a RegistryNotReadyError, see
_serialize_bec_connector.
Args:
request_id (str): The ID of the request.
res (object): The result of the RPC call.
"""
retry_delay = 100
try:
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
elif isinstance(res, dict):
res = {key: self.serialize_object(val) for key, val in res.items()}
else:
res = self.serialize_object(res)
except RegistryNotReadyError:
try:
self._rpc_singleshot_repeats[request_id] += retry_delay
QTimer.singleShot(
retry_delay, lambda: self.serialize_result_and_send(request_id, res)
)
except RegistryNotReadyError:
logger.error(
f"Max delay exceeded for RPC request {request_id}, sending error response"
)
self.send_response(
request_id,
False,
{
"error": f"Max delay exceeded for RPC request {request_id}, object not registered in time."
},
)
self._rpc_singleshot_repeats.pop(request_id, None)
return
except Exception as exc:
logger.error(f"Error while serializing RPC result: {exc}")
self.send_response(
request_id,
False,
{"error": f"Error while serializing RPC result: {exc}\n{traceback.format_exc()}"},
)
else:
self.send_response(request_id, True, {"result": res})
self._rpc_singleshot_repeats.pop(request_id, None)
return res
def serialize_object(self, obj: T) -> None | dict | T:
"""
@@ -320,8 +256,11 @@ class RPCServer:
except Exception:
container_proxy = None
if wait and not self.rpc_register.object_is_registered(connector):
raise RegistryNotReadyError(f"Connector {connector} not registered yet")
if wait:
while not self.rpc_register.object_is_registered(connector):
QApplication.processEvents()
logger.info(f"Waiting for {connector} to be registered...")
time.sleep(0.1)
widget_class = getattr(connector, "rpc_widget_class", None)
if not widget_class:

View File

@@ -1,100 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from qtpy.QtWidgets import QApplication, QWidget
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtCore import QRect
def available_screen_geometry(*, widget: QWidget | None = None) -> QRect | None:
"""
Get the available geometry of the screen associated with the given widget or application.
Args:
widget(QWidget | None): The widget to get the screen from.
Returns:
QRect | None: The available geometry of the screen, or None if no screen is found.
"""
screen = widget.screen() if widget is not None else None
if screen is None:
app = QApplication.instance()
screen = app.primaryScreen() if app is not None else None
if screen is None:
return None
return screen.availableGeometry()
def centered_geometry(available: "QRect", width: int, height: int) -> tuple[int, int, int, int]:
"""
Calculate centered geometry within the available rectangle.
Args:
available(QRect): The available rectangle to center within.
width(int): The desired width.
height(int): The desired height.
Returns:
tuple[int, int, int, int]: The (x, y, width, height) of the centered geometry.
"""
x = available.x() + (available.width() - width) // 2
y = available.y() + (available.height() - height) // 2
return x, y, width, height
def centered_geometry_for_app(width: int, height: int) -> tuple[int, int, int, int] | None:
available = available_screen_geometry()
if available is None:
return None
return centered_geometry(available, width, height)
def scaled_centered_geometry_for_window(
window: QWidget, *, width_ratio: float = 0.8, height_ratio: float = 0.8
) -> tuple[int, int, int, int] | None:
available = available_screen_geometry(widget=window)
if available is None:
return None
width = int(available.width() * width_ratio)
height = int(available.height() * height_ratio)
return centered_geometry(available, width, height)
def apply_window_geometry(
window: QWidget,
geometry: tuple[int, int, int, int] | None,
*,
width_ratio: float = 0.8,
height_ratio: float = 0.8,
) -> None:
if geometry is not None:
window.setGeometry(*geometry)
return
default_geometry = scaled_centered_geometry_for_window(
window, width_ratio=width_ratio, height_ratio=height_ratio
)
if default_geometry is not None:
window.setGeometry(*default_geometry)
else:
window.resize(window.minimumSizeHint())
def main_app_size_for_screen(available: "QRect") -> tuple[int, int]:
height = int(available.height() * 0.9)
width = int(height * (16 / 9))
if width > available.width() * 0.9:
width = int(available.width() * 0.9)
height = int(width / (16 / 9))
return width, height
def apply_centered_size(
window: QWidget, width: int, height: int, *, available: "QRect" | None = None
) -> None:
if available is None:
available = available_screen_geometry(widget=window)
if available is None:
window.resize(width, height)
return
window.setGeometry(*centered_geometry(available, width, height))

View File

@@ -5,8 +5,7 @@ import os
import weakref
from abc import ABC, abstractmethod
from contextlib import contextmanager
from enum import Enum
from typing import Dict, Literal, Union
from typing import Dict, Literal
from bec_lib.device import ReadoutPriority
from bec_lib.logger import bec_logger
@@ -16,7 +15,6 @@ from qtpy.QtGui import QAction, QColor, QIcon # type: ignore
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QGraphicsDropShadowEffect,
QHBoxLayout,
QLabel,
QMenu,
@@ -28,8 +26,6 @@ from qtpy.QtWidgets import (
)
import bec_widgets
from bec_widgets.utils.colors import AccentColors, get_accent_colors
from bec_widgets.utils.toolbars.splitter import ResizableSpacer
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
@@ -105,205 +101,6 @@ class LongPressToolButton(QToolButton):
self.showMenu()
class StatusState(str, Enum):
DEFAULT = "default"
HIGHLIGHT = "highlight"
WARNING = "warning"
EMERGENCY = "emergency"
SUCCESS = "success"
class StatusIndicatorWidget(QWidget):
"""Pill-shaped status indicator with icon + label using accent colors."""
def __init__(
self, parent=None, text: str = "Ready", state: StatusState | str = StatusState.DEFAULT
):
super().__init__(parent)
self.setObjectName("StatusIndicatorWidget")
self._text = text
self._state = self._normalize_state(state)
self._theme_connected = False
layout = QHBoxLayout(self)
layout.setContentsMargins(6, 2, 8, 2)
layout.setSpacing(6)
self._icon_label = QLabel(self)
self._icon_label.setFixedSize(18, 18)
self._text_label = QLabel(self)
self._text_label.setText(self._text)
layout.addWidget(self._icon_label)
layout.addWidget(self._text_label)
# Give it a consistent pill height
self.setMinimumHeight(24)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
# Soft shadow similar to notification banners
self._shadow = QGraphicsDropShadowEffect(self)
self._shadow.setBlurRadius(18)
self._shadow.setOffset(0, 2)
self.setGraphicsEffect(self._shadow)
self._apply_state(self._state)
self._connect_theme_change()
def set_state(self, state: Union[StatusState, str]):
"""Update state and refresh visuals."""
self._state = self._normalize_state(state)
self._apply_state(self._state)
def set_text(self, text: str):
"""Update the displayed text."""
self._text = text
self._text_label.setText(text)
def _apply_state(self, state: StatusState):
palette = self._resolve_accent_colors()
color_attr = {
StatusState.DEFAULT: "default",
StatusState.HIGHLIGHT: "highlight",
StatusState.WARNING: "warning",
StatusState.EMERGENCY: "emergency",
StatusState.SUCCESS: "success",
}.get(state, "default")
base_color = getattr(palette, color_attr, None) or getattr(
palette, "default", QColor("gray")
)
# Apply style first (returns text color for label)
text_color = self._update_style(base_color, self._theme_fg_color())
theme_name = self._theme_name()
# Choose icon per state
icon_name_map = {
StatusState.DEFAULT: "check_circle",
StatusState.HIGHLIGHT: "check_circle",
StatusState.SUCCESS: "check_circle",
StatusState.WARNING: "warning",
StatusState.EMERGENCY: "dangerous",
}
icon_name = icon_name_map.get(state, "check_circle")
# Icon color:
# - Dark mode: follow text color (usually white) for high contrast.
# - Light mode: use a stronger version of the accent color for a colored glyph
# that stands out on the pastel pill background.
if theme_name == "light":
icon_q = QColor(base_color)
icon_color = icon_q.name(QColor.HexRgb)
else:
icon_color = text_color
icon = material_icon(
icon_name, size=(18, 18), convert_to_pixmap=False, filled=True, color=icon_color
)
if not icon.isNull():
self._icon_label.setPixmap(icon.pixmap(18, 18))
def _update_style(self, color: QColor, fg_color: QColor) -> str:
# Ensure the widget actually paints its own background
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
fg = QColor(fg_color)
text_color = fg.name(QColor.HexRgb)
theme_name = self._theme_name()
base = QColor(color)
start = QColor(base)
end = QColor(base)
border = QColor(base)
if theme_name == "light":
start.setAlphaF(0.20)
end.setAlphaF(0.06)
else:
start.setAlphaF(0.35)
end.setAlphaF(0.12)
border = border.darker(120)
# shadow color tuned per theme to match notification banners
if hasattr(self, "_shadow"):
if theme_name == "light":
shadow_color = QColor(15, 23, 42, 60) # softer shadow on light bg
else:
shadow_color = QColor(0, 0, 0, 160)
self._shadow.setColor(shadow_color)
# Use a fixed radius for a stable pill look inside toolbars
radius = 10
self.setStyleSheet(
f"""
#StatusIndicatorWidget {{
background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:1, y2:1,
stop:0 {start.name(QColor.HexArgb)}, stop:1 {end.name(QColor.HexArgb)});
border: 1px solid {border.name(QColor.HexRgb)};
border-radius: {radius}px;
padding: 2px 8px;
}}
#StatusIndicatorWidget QLabel {{
color: {text_color};
background: transparent;
}}
"""
)
return text_color
def _theme_fg_color(self) -> QColor:
app = QApplication.instance()
theme = getattr(app, "theme", None)
if theme is not None and hasattr(theme, "color"):
try:
fg = theme.color("FG")
if isinstance(fg, QColor):
return fg
except Exception:
pass
palette = self._resolve_accent_colors()
base = getattr(palette, "default", QColor("white"))
luminance = (0.299 * base.red() + 0.587 * base.green() + 0.114 * base.blue()) / 255
return QColor("#000000") if luminance > 0.65 else QColor("#ffffff")
def _theme_name(self) -> str:
app = QApplication.instance()
theme = getattr(app, "theme", None)
name = getattr(theme, "theme", None)
if isinstance(name, str):
return name.lower()
return "dark"
def _connect_theme_change(self):
if self._theme_connected:
return
app = QApplication.instance()
theme = getattr(app, "theme", None)
if theme is not None and hasattr(theme, "theme_changed"):
try:
theme.theme_changed.connect(lambda _: self._apply_state(self._state))
self._theme_connected = True
except Exception:
pass
@staticmethod
def _normalize_state(state: Union[StatusState, str]) -> StatusState:
if isinstance(state, StatusState):
return state
try:
return StatusState(state)
except ValueError:
return StatusState.DEFAULT
@staticmethod
def _resolve_accent_colors() -> AccentColors:
return get_accent_colors()
class ToolBarAction(ABC):
"""
Abstract base class for toolbar actions.
@@ -350,54 +147,6 @@ class SeparatorAction(ToolBarAction):
toolbar.addSeparator()
class StatusIndicatorAction(ToolBarAction):
"""Toolbar action hosting a LED indicator and status text."""
def __init__(
self,
*,
text: str = "Ready",
state: Union[StatusState, str] = StatusState.DEFAULT,
tooltip: str | None = None,
):
super().__init__(icon_path=None, tooltip=tooltip or "View status", checkable=False)
self._text = text
self._state: StatusState = StatusIndicatorWidget._normalize_state(state)
self.widget: StatusIndicatorWidget | None = None
self.tooltip = tooltip or ""
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
if (
self.widget is None
or self.widget.parent() is None
or self.widget.parent() is not toolbar
):
self.widget = StatusIndicatorWidget(parent=toolbar, text=self._text, state=self._state)
self.action = toolbar.addWidget(self.widget)
self.action.setText(self._text)
self.set_tooltip(self.tooltip)
def set_state(self, state: Union[StatusState, str]):
self._state = StatusIndicatorWidget._normalize_state(state)
if self.widget is not None:
self.widget.set_state(self._state)
def set_text(self, text: str):
self._text = text
if self.widget is not None:
self.widget.set_text(text)
if hasattr(self, "action") and self.action is not None:
self.action.setText(text)
def set_tooltip(self, tooltip: str | None):
"""Set tooltip on both the underlying widget and the QWidgetAction."""
self.tooltip = tooltip or ""
if self.widget is not None:
self.widget.setToolTip(self.tooltip)
if hasattr(self, "action") and self.action is not None:
self.action.setToolTip(self.tooltip)
class QtIconAction(IconAction):
def __init__(
self,
@@ -749,82 +498,6 @@ class WidgetAction(ToolBarAction):
return max_width + 60
class SplitterAction(ToolBarAction):
"""
Action for adding a draggable splitter/spacer to the toolbar.
This creates a resizable spacer that allows users to control how much space
is allocated to toolbar sections before and after it. When dragged, it expands/contracts,
pushing other toolbar elements left or right.
Args:
orientation (Literal["horizontal", "vertical", "auto"]): The orientation of the splitter.
parent (QWidget): The parent widget.
initial_width (int): Fixed size of the spacer in pixels along the toolbar's orientation (default: 20).
min_width (int | None): Minimum size of the target widget along the orientation axis (width for horizontal, height for vertical). If ``None``, no minimum constraint is applied.
max_width (int | None): Maximum size of the target widget along the orientation axis (width for horizontal, height for vertical). If ``None``, no maximum constraint is applied.
target_widget (QWidget | None): Widget whose size (width or height, depending on orientation) is controlled by the spacer within the given min/max bounds.
"""
def __init__(
self,
orientation: Literal["horizontal", "vertical", "auto"] = "auto",
parent=None,
initial_width=20,
min_width: int | None = None,
max_width: int | None = None,
target_widget=None,
):
super().__init__(icon_path=None, tooltip="Drag to resize toolbar sections", checkable=False)
self.orientation = orientation
self.initial_width = initial_width
self.min_width = min_width
self.max_width = max_width
self._splitter_widget = None
self._target_widget = target_widget
def _resolve_orientation(self, toolbar: QToolBar) -> Literal["horizontal", "vertical"]:
if self.orientation in (None, "auto"):
return (
"horizontal" if toolbar.orientation() == Qt.Orientation.Horizontal else "vertical"
)
return self.orientation
def set_target_widget(self, widget):
"""Set the target widget after creation."""
self._target_widget = widget
if self._splitter_widget:
self._splitter_widget.set_target_widget(widget)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the splitter/spacer to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the splitter to.
target (QWidget): The target widget for the action.
"""
effective_orientation = self._resolve_orientation(toolbar)
self._splitter_widget = ResizableSpacer(
parent=target,
orientation=effective_orientation,
initial_width=self.initial_width,
min_target_size=self.min_width,
max_target_size=self.max_width,
target_widget=self._target_widget,
)
toolbar.addWidget(self._splitter_widget)
self.action = self._splitter_widget # type: ignore
def cleanup(self):
"""Clean up the splitter widget."""
if self._splitter_widget is not None:
self._splitter_widget.close()
self._splitter_widget.deleteLater()
return super().cleanup()
class ExpandableMenuAction(ToolBarAction):
"""
Action for an expandable menu in the toolbar.

View File

@@ -7,17 +7,10 @@ from weakref import ReferenceType
import louie
from bec_lib.logger import bec_logger
from pydantic import BaseModel
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QSizePolicy
from bec_widgets.utils.toolbars.actions import SeparatorAction, SplitterAction, ToolBarAction
DEFAULT_SIZE = 400
MAX_SIZE = 10_000_000
from bec_widgets.utils.toolbars.actions import SeparatorAction, ToolBarAction
if TYPE_CHECKING:
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.toolbars.connections import BundleConnection
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
@@ -202,84 +195,6 @@ class ToolbarBundle:
"""
self.add_action("separator")
def add_splitter(
self,
name: str = "splitter",
target_widget: QWidget | None = None,
initial_width: int = 10,
min_width: int | None = None,
max_width: int | None = None,
size_policy_expanding: bool = True,
):
"""
Adds a resizable splitter action to the bundle.
Args:
name (str): Unique identifier for the splitter action.
target_widget (QWidget, optional): The widget whose size (width for horizontal,
height for vertical orientation) will be controlled by the splitter. If None,
the splitter will not control any widget.
initial_width (int): The initial size of the splitter (width for horizontal,
height for vertical orientation).
min_width (int, optional): The minimum size the target widget can be resized to
(width for horizontal, height for vertical orientation). If None, the target
widget's minimum size hint in that orientation will be used.
max_width (int, optional): The maximum size the target widget can be resized to
(width for horizontal, height for vertical orientation). If None, the target
widget's maximum size hint in that orientation will be used.
size_policy_expanding (bool): If True, the size policy of the target_widget will be
set to Expanding in the appropriate orientation if it is not already set.
"""
# Resolve effective bounds
eff_min = min_width if min_width is not None else None
eff_max = max_width if max_width is not None else None
is_horizontal = self.components.toolbar.orientation() == Qt.Orientation.Horizontal
if target_widget is not None:
# Use widget hints if bounds not provided
if eff_min is None:
eff_min = (
target_widget.minimumWidth() if is_horizontal else target_widget.minimumHeight()
) or 6
if eff_max is None:
mw = (
target_widget.maximumWidth() if is_horizontal else target_widget.maximumHeight()
)
eff_max = mw if mw and mw < MAX_SIZE else DEFAULT_SIZE # avoid "no limit"
# Adjust size policy if needed
if size_policy_expanding:
size_policy = target_widget.sizePolicy()
if is_horizontal:
if size_policy.horizontalPolicy() not in (
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.MinimumExpanding,
):
size_policy.setHorizontalPolicy(QSizePolicy.Policy.Expanding)
target_widget.setSizePolicy(size_policy)
else:
if size_policy.verticalPolicy() not in (
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.MinimumExpanding,
):
size_policy.setVerticalPolicy(QSizePolicy.Policy.Expanding)
target_widget.setSizePolicy(size_policy)
splitter_action = SplitterAction(
orientation="auto",
parent=self.components.toolbar,
initial_width=initial_width,
min_width=eff_min,
max_width=eff_max,
target_widget=target_widget,
)
self.components.add_safe(name, splitter_action)
self.add_action(name)
def add_connection(self, name: str, connection):
"""
Adds a connection to the bundle.

View File

@@ -1,136 +1,18 @@
from __future__ import annotations
from abc import abstractmethod
from typing import Callable
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject
logger = bec_logger.logger
class BundleConnection(QObject):
"""
Base class for toolbar bundle connections.
Provides infrastructure for bidirectional property-toolbar synchronization:
- Toolbar actions → Widget properties (via action.triggered connections)
- Widget properties → Toolbar actions (via property_changed signal)
"""
bundle_name: str
def __init__(self, parent=None):
super().__init__(parent)
self._property_sync_methods: dict[str, Callable] = {}
self._property_sync_connected = False
def register_property_sync(self, prop_name: str, sync_method: Callable):
"""
Register a method to synchronize toolbar state when a property changes.
This enables automatic toolbar updates when properties are set programmatically,
restored from QSettings, or changed via RPC.
Args:
prop_name: The property name to watch (e.g., "fft", "log", "x_grid")
sync_method: Method to call when property changes. Should accept the new value
and update toolbar state (typically with signals blocked to prevent loops)
Example:
def _sync_fft_toolbar(self, value: bool):
self.fft_action.blockSignals(True)
self.fft_action.setChecked(value)
self.fft_action.blockSignals(False)
self.register_property_sync("fft", self._sync_fft_toolbar)
"""
self._property_sync_methods[prop_name] = sync_method
def _resolve_action(self, action_like):
if hasattr(action_like, "action"):
return action_like.action
return action_like
def register_checked_action_sync(self, prop_name: str, action_like):
"""
Register a property sync for a checkable QAction (or wrapper with .action).
This reduces boilerplate for simple boolean → checked state updates.
"""
qt_action = self._resolve_action(action_like)
def _sync_checked(value):
qt_action.blockSignals(True)
try:
qt_action.setChecked(bool(value))
finally:
qt_action.blockSignals(False)
self.register_property_sync(prop_name, _sync_checked)
def connect_property_sync(self, target_widget):
"""
Connect to target widget's property_changed signal for automatic toolbar sync.
Call this in your connect() method after registering all property syncs.
Args:
target_widget: The widget to monitor for property changes
"""
if self._property_sync_connected:
return
if hasattr(target_widget, "property_changed"):
target_widget.property_changed.connect(self._on_property_changed)
self._property_sync_connected = True
else:
logger.warning(
f"{target_widget.__class__.__name__} does not have property_changed signal. "
"Property-toolbar sync will not work."
)
def disconnect_property_sync(self, target_widget):
"""
Disconnect from target widget's property_changed signal.
Call this in your disconnect() method.
Args:
target_widget: The widget to stop monitoring
"""
if not self._property_sync_connected:
return
if hasattr(target_widget, "property_changed"):
try:
target_widget.property_changed.disconnect(self._on_property_changed)
except (RuntimeError, TypeError):
# Signal already disconnected or connection doesn't exist
pass
self._property_sync_connected = False
def _on_property_changed(self, prop_name: str, value):
"""
Internal handler for property changes.
Calls the registered sync method for the changed property.
"""
if prop_name in self._property_sync_methods:
try:
self._property_sync_methods[prop_name](value)
except Exception as e:
logger.error(
f"Error syncing toolbar for property '{prop_name}': {e}", exc_info=True
)
@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.
Subclasses should call connect_property_sync(target_widget) if property sync is needed.
"""
@abstractmethod
@@ -138,6 +20,4 @@ class BundleConnection(QObject):
"""
Disconnects the bundle from the target widget or application.
This method should be implemented by subclasses to define how to clean up connections.
Subclasses should call disconnect_property_sync(target_widget) if property sync was connected.
"""

View File

@@ -1,241 +0,0 @@
"""
Draggable splitter for toolbars to allow resizing of toolbar sections.
"""
from typing import Literal
from bec_qthemes import material_icon
from qtpy.QtCore import QPoint, QSize, Qt, Signal
from qtpy.QtGui import QPainter
from qtpy.QtWidgets import QSizePolicy, QWidget
class ResizableSpacer(QWidget):
"""
A resizable spacer widget for toolbars that can be dragged to expand/contract.
When connected to a widget, it controls that widget's size along the spacer's
orientation (maximum width for horizontal, maximum height for vertical),
ensuring the widget stays flush against the spacer with no gaps.
Args:
parent(QWidget | None): Parent widget.
orientation(Literal["horizontal", "vertical"]): Orientation of the spacer.
initial_width(int): Initial size of the spacer in pixels along the orientation
(width for horizontal, height for vertical).
min_target_size(int): Minimum size of the target widget when resized along the
orientation (width for horizontal, height for vertical).
max_target_size(int): Maximum size of the target widget when resized along the
orientation (width for horizontal, height for vertical).
target_widget: QWidget | None. The widget whose size along the orientation
is controlled by this spacer.
"""
size_changed = Signal(int)
def __init__(
self,
parent=None,
orientation: Literal["horizontal", "vertical"] = "horizontal",
initial_width: int = 10,
min_target_size: int = 6,
max_target_size: int = 500,
target_widget: QWidget = None,
):
from bec_widgets.utils.toolbars.bundles import DEFAULT_SIZE, MAX_SIZE
super().__init__(parent)
self._target_start_size = None
self.orientation = orientation
self._current_width = initial_width
self._min_width = min_target_size
self._max_width = max_target_size
self._dragging = False
self._drag_start_pos = QPoint()
self._target_widget = target_widget
# Determine bounds from kwargs or target hints
is_horizontal = orientation == "horizontal"
target_min = target_widget.minimumWidth() if (target_widget and is_horizontal) else 0
if target_widget and not is_horizontal:
target_min = target_widget.minimumHeight()
target_hint = target_widget.sizeHint().width() if (target_widget and is_horizontal) else 0
if target_widget and not is_horizontal:
target_hint = target_widget.sizeHint().height()
target_max_hint = (
target_widget.maximumWidth() if (target_widget and is_horizontal) else None
)
if target_widget and not is_horizontal:
target_max_hint = target_widget.maximumHeight()
self._min_target = min_target_size if min_target_size is not None else (target_min or 6)
self._max_target = (
max_target_size
if max_target_size is not None
else (
target_max_hint if target_max_hint and target_max_hint < MAX_SIZE else DEFAULT_SIZE
)
)
# Determine a reasonable base width and clamp to bounds
if target_widget:
current_size = target_widget.width() if is_horizontal else target_widget.height()
if current_size > 0:
self._base_width = current_size
elif target_min > 0:
self._base_width = target_min
elif target_hint > 0:
self._base_width = target_hint
else:
self._base_width = 240
else:
self._base_width = 240
self._base_width = max(self._min_target, min(self._max_target, self._base_width))
# Set size constraints - Fixed policy to prevent automatic resizing
# Match toolbar height for proper alignment
self._toolbar_height = 32 # Standard toolbar height
if orientation == "horizontal":
self.setFixedWidth(initial_width)
self.setFixedHeight(self._toolbar_height)
self.setCursor(Qt.CursorShape.SplitHCursor)
else:
self.setFixedHeight(initial_width)
self.setFixedWidth(self._toolbar_height)
self.setCursor(Qt.CursorShape.SplitVCursor)
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.setStyleSheet(
"""
ResizableSpacer {
background-color: transparent;
margin: 0px;
padding: 0px;
border: none;
}
ResizableSpacer:hover {
background-color: rgba(100, 100, 200, 80);
}
"""
)
self.setContentsMargins(0, 0, 0, 0)
if self._target_widget:
size_policy = self._target_widget.sizePolicy()
if is_horizontal:
vertical_policy = size_policy.verticalPolicy()
self._target_widget.setSizePolicy(QSizePolicy.Policy.Fixed, vertical_policy)
else:
horizontal_policy = size_policy.horizontalPolicy()
self._target_widget.setSizePolicy(horizontal_policy, QSizePolicy.Policy.Fixed)
# Load Material icon based on orientation
icon_name = "more_vert" if orientation == "horizontal" else "more_horiz"
icon_size = 24
self._icon = material_icon(icon_name, size=(icon_size, icon_size), convert_to_pixmap=False)
self._icon_size = icon_size
def set_target_widget(self, widget):
"""Set the widget whose size is controlled by this spacer."""
self._target_widget = widget
if widget:
is_horizontal = self.orientation == "horizontal"
target_min = widget.minimumWidth() if is_horizontal else widget.minimumHeight()
target_hint = widget.sizeHint().width() if is_horizontal else widget.sizeHint().height()
target_max_hint = widget.maximumWidth() if is_horizontal else widget.maximumHeight()
self._min_target = self._min_target or (target_min or 6)
self._max_target = (
self._max_target
if self._max_target is not None
else (target_max_hint if target_max_hint and target_max_hint < 10_000_000 else 400)
)
current_size = widget.width() if is_horizontal else widget.height()
if current_size is not None and current_size > 0:
base = current_size
elif target_min is not None and target_min > 0:
base = target_min
elif target_hint is not None and target_hint > 0:
base = target_hint
else:
base = self._base_width
base = max(self._min_target, min(self._max_target, base))
if is_horizontal:
widget.setFixedWidth(base)
else:
widget.setFixedHeight(base)
def get_target_widget(self):
"""Get the widget whose size is controlled by this spacer."""
return self._target_widget
def sizeHint(self):
if self.orientation == "horizontal":
return QSize(self._current_width, self._toolbar_height)
else:
return QSize(self._toolbar_height, self._current_width)
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Draw the Material icon centered in the widget using stored icon size
x = (self.width() - self._icon_size) // 2
y = (self.height() - self._icon_size) // 2
self._icon.paint(painter, x, y, self._icon_size, self._icon_size)
painter.end()
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._dragging = True
self._drag_start_pos = event.globalPos()
# Store target's current width if it exists
if self._target_widget:
if self.orientation == "horizontal":
self._target_start_size = self._target_widget.width()
else:
self._target_start_size = self._target_widget.height()
size_policy = self._target_widget.sizePolicy()
if self.orientation == "horizontal":
vertical_policy = size_policy.verticalPolicy()
self._target_widget.setSizePolicy(QSizePolicy.Policy.Fixed, vertical_policy)
self._target_widget.setFixedWidth(self._target_start_size)
else:
horizontal_policy = size_policy.horizontalPolicy()
self._target_widget.setSizePolicy(horizontal_policy, QSizePolicy.Policy.Fixed)
self._target_widget.setFixedHeight(self._target_start_size)
event.accept()
def mouseMoveEvent(self, event):
if self._dragging:
current_pos = event.globalPos()
delta = current_pos - self._drag_start_pos
if self.orientation == "horizontal":
delta_pixels = delta.x()
else:
delta_pixels = delta.y()
if self._target_widget:
new_target_size = self._target_start_size + delta_pixels
new_target_size = max(self._min_target, min(self._max_target, new_target_size))
if self.orientation == "horizontal":
if new_target_size != self._target_widget.width():
self._target_widget.setFixedWidth(new_target_size)
self.size_changed.emit(new_target_size)
else:
if new_target_size != self._target_widget.height():
self._target_widget.setFixedHeight(new_target_size)
self.size_changed.emit(new_target_size)
event.accept()
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._dragging = False
event.accept()

View File

@@ -1,283 +0,0 @@
from __future__ import annotations
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import BeamlineStateConfig
from qtpy.QtCore import QObject, QTimer, Signal
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import StatusIndicatorAction, StatusState
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
logger = bec_logger.logger
class BECStatusBroker(BECConnector, QObject):
"""Listen to BEC beamline state endpoints and emit structured signals."""
_instance: "BECStatusBroker | None" = None
_initialized: bool = False
available_updated = Signal(list) # list of states available
status_updated = Signal(str, dict) # name, status update
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, parent=None, gui_id: str | None = None, client=None, **kwargs):
if self._initialized:
return
super().__init__(parent=parent, gui_id=gui_id, client=client, **kwargs)
self._watched: set[str] = set()
self.bec_dispatcher.connect_slot(
self.on_available, MessageEndpoints.available_beamline_states()
)
self._initialized = True
self.refresh_available()
def refresh_available(self):
"""Fetch the current set of beamline conditions once."""
try:
msg = self.client.connector.get_last(MessageEndpoints.available_beamline_states())
logger.info(f"StatusBroker: fetched available conditions payload: {msg}")
if msg:
self.on_available(msg.get("data").content, None)
except Exception as exc: # pragma: no cover - runtime env
logger.debug(f"Could not fetch available conditions: {exc}")
@SafeSlot(dict, dict)
def on_available(self, data: dict, meta: dict | None = None):
state_list = data.get("states") # latest one from the stream
self.available_updated.emit(state_list)
for state in state_list:
name = state.name
if name:
self.watch_state(name)
def watch_state(self, name: str):
"""Subscribe to updates for a single beamline state."""
if name in self._watched:
return
self._watched.add(name)
endpoint = MessageEndpoints.beamline_state(name)
logger.info(f"StatusBroker: watching state '{name}' on {endpoint.endpoint}")
self.bec_dispatcher.connect_slot(self.on_state, endpoint)
self.fetch_state(name)
def fetch_state(self, name: str):
"""Fetch the current value of a beamline state once."""
endpoint = MessageEndpoints.beamline_state(name)
try:
msg = self.client.connector.get_last(endpoint)
logger.info(f"StatusBroker: fetched state '{name}' payload: {msg}")
if msg:
self.on_state(msg.get("data").content, None)
except Exception as exc: # pragma: no cover - runtime env
logger.debug(f"Could not fetch state {name}: {exc}")
@SafeSlot(dict, dict)
def on_state(self, data: dict, meta: dict | None = None):
name = data.get("name")
if not name:
return
logger.info(f"StatusBroker: state update for '{name}' -> {data}")
self.status_updated.emit(str(name), data)
@classmethod
def reset_singleton(cls):
"""
Reset the singleton instance of the BECStatusBroker.
"""
cls._instance = None
cls._initialized = False
class StatusToolBar(ModularToolBar):
"""Status toolbar that auto-manages beamline state indicators."""
STATUS_MAP: dict[str, StatusState] = {
"valid": StatusState.SUCCESS,
"warning": StatusState.WARNING,
"invalid": StatusState.EMERGENCY,
}
def __init__(self, parent=None, names: list[str] | None = None, **kwargs):
super().__init__(parent=parent, orientation="horizontal", **kwargs)
self.setObjectName("StatusToolbar")
self._status_bundle = self.new_bundle("status")
self.show_bundles(["status"])
self._apply_status_toolbar_style()
self.allowed_names: set[str] | None = set(names) if names is not None else None
logger.info(f"StatusToolbar init allowed_names={self.allowed_names}")
self.broker = BECStatusBroker()
self.broker.available_updated.connect(self.on_available_updated)
self.broker.status_updated.connect(self.on_status_updated)
QTimer.singleShot(0, self.refresh_from_broker)
def refresh_from_broker(self) -> None:
if self.allowed_names is None:
self.broker.refresh_available()
else:
for name in self.allowed_names:
if not self.components.exists(name):
# Pre-create a placeholder pill so it is visible even before data arrives.
self.add_status_item(
name=name, text=name, state=StatusState.DEFAULT, tooltip=None
)
self.broker.watch_state(name)
def _apply_status_toolbar_style(self) -> None:
self.setStyleSheet(
"QToolBar#StatusToolbar {"
f" background-color: {self.background_color};"
" border: none;"
" border-bottom: 1px solid palette(mid);"
"}"
)
# -------- Slots for updates --------
@SafeSlot(list)
def on_available_updated(self, available_states: list):
"""Process the available states stream and start watching them."""
# Keep track of current names from the broker to remove stale ones.
current_names: set[str] = set()
for state in available_states:
if not isinstance(state, BeamlineStateConfig):
continue
name = state.name
title = state.title or name
if not name:
continue
current_names.add(name)
logger.info(f"StatusToolbar: discovered state '{name}' title='{title}'")
# auto-add unless filtered out
if self.allowed_names is None or name in self.allowed_names:
self.add_status_item(name=name, text=title, state=StatusState.DEFAULT, tooltip=None)
else:
# keep hidden but present for context menu toggling
self.add_status_item(name=name, text=title, state=StatusState.DEFAULT, tooltip=None)
act = self.components.get_action(name)
if act and act.action:
act.action.setVisible(False)
# Remove actions that are no longer present in available_states.
known_actions = [
n for n in self.components._components.keys() if n not in ("separator",)
] # direct access used for clean-up
for name in known_actions:
if name not in current_names:
logger.info(f"StatusToolbar: removing stale state '{name}'")
try:
self.components.remove_action(name)
except Exception as exc:
logger.warning(f"Failed to remove stale state '{name}': {exc}")
self.refresh()
@SafeSlot(str, dict)
def on_status_updated(self, name: str, payload: dict): # TODO finish update logic
"""Update a status pill when a state update arrives."""
state = self.STATUS_MAP.get(str(payload.get("status", "")).lower(), StatusState.DEFAULT)
action = self.components.get_action(name) if self.components.exists(name) else None
# Only update the label when a title is explicitly provided; otherwise keep current text.
title = payload.get("title") or None
text = title
if text is None and action is None:
text = payload.get("name") or name
if "label" in payload:
tooltip = payload.get("label") or ""
else:
tooltip = None
logger.info(
f"StatusToolbar: update state '{name}' -> state={state} text='{text}' tooltip='{tooltip}'"
)
self.set_status(name=name, text=text, state=state, tooltip=tooltip)
# -------- Items Management --------
def add_status_item(
self,
name: str,
*,
text: str = "Ready",
state: StatusState | str = StatusState.DEFAULT,
tooltip: str | None = None,
) -> StatusIndicatorAction | None:
"""
Add or update a named status item in the toolbar.
After you added all actions, call `toolbar.refresh()` to update the display.
Args:
name(str): Unique name for the status item.
text(str): Text to display in the status item.
state(StatusState | str): State of the status item.
tooltip(str | None): Optional tooltip for the status item.
Returns:
StatusIndicatorAction | None: The created or updated status action, or None if toolbar is not initialized.
"""
if self._status_bundle is None:
return
if self.components.exists(name):
return
action = StatusIndicatorAction(text=text, state=state, tooltip=tooltip)
return self.add_status_action(name, action)
def add_status_action(
self, name: str, action: StatusIndicatorAction
) -> StatusIndicatorAction | None:
"""
Attach an existing StatusIndicatorAction to the status toolbar.
After you added all actions, call `toolbar.refresh()` to update the display.
Args:
name(str): Unique name for the status item.
action(StatusIndicatorAction): The status action to add.
Returns:
StatusIndicatorAction | None: The added status action, or None if toolbar is not initialized.
"""
self.components.add_safe(name, action)
self.get_bundle("status").add_action(name)
self.refresh()
self.broker.fetch_state(name)
return action
def set_status(
self,
name: str = "main",
*,
state: StatusState | str | None = None,
text: str | None = None,
tooltip: str | None = None,
) -> None:
"""
Update the status item with the given name, creating it if necessary.
Args:
name(str): Unique name for the status item.
state(StatusState | str | None): New state for the status item.
text(str | None): New text for the status item.
"""
action = self.components.get_action(name) if self.components.exists(name) else None
if action is None:
action = self.add_status_item(
name, text=text or "Ready", state=state or "default", tooltip=tooltip
)
if action is None:
return
if state is not None:
action.set_state(state)
if text is not None:
action.set_text(text)
if tooltip is not None and hasattr(action, "set_tooltip"):
action.set_tooltip(tooltip)

View File

@@ -8,19 +8,10 @@ from typing import DefaultDict, Literal
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt, QTimer
from qtpy.QtGui import QAction, QColor
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QLabel,
QMainWindow,
QMenu,
QToolBar,
QVBoxLayout,
QWidget,
)
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
from bec_widgets.utils.colors import apply_theme, get_theme_name
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction, WidgetAction
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
@@ -415,18 +406,9 @@ class ModularToolBar(QToolBar):
def update_separators(self):
"""
Hide separators that are adjacent to another separator, splitters, or have no non-separator actions between them.
Splitters (ResizableSpacer) already provide visual separation, so we don't need separators next to them.
Hide separators that are adjacent to another separator or have no non-separator actions between them.
"""
from bec_widgets.utils.toolbars.splitter import ResizableSpacer
toolbar_actions = self.actions()
# Helper function to check if a widget is a splitter
def is_splitter_widget(action):
widget = self.widgetForAction(action)
return widget is not None and isinstance(widget, ResizableSpacer)
# First pass: set visibility based on surrounding non-separator actions.
for i, action in enumerate(toolbar_actions):
if not action.isSeparator():
@@ -441,32 +423,23 @@ class ModularToolBar(QToolBar):
if toolbar_actions[j].isVisible():
next_visible = toolbar_actions[j]
break
# Hide separator if adjacent to another separator, splitter, or at edges
if (
prev_visible is None
or prev_visible.isSeparator()
or is_splitter_widget(prev_visible)
) and (
next_visible is None
or next_visible.isSeparator()
or is_splitter_widget(next_visible)
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, and no separators next to splitters.
# Second pass: ensure no two visible separators are adjacent.
prev = None
for action in toolbar_actions:
if action.isVisible():
if action.isSeparator():
# Hide separator if previous visible item was a separator or splitter
if prev and (prev.isSeparator() or is_splitter_widget(prev)):
action.setVisible(False)
else:
prev = action
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
@@ -508,31 +481,12 @@ if __name__ == "__main__": # pragma: no cover
self.setWindowTitle("Toolbar / ToolbarBundle Demo")
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
self.test_label = QLabel(text="Drag the splitter (⋮) to resize!")
self.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)
# Example: Bare combobox (no container). Give it a stable starting width
self.example_combo = QComboBox(parent=self)
self.example_combo.addItems(["device_1", "device_2", "device_3"])
self.toolbar.components.add_safe(
"example_combo", WidgetAction(widget=self.example_combo)
)
# Create a bundle with the combobox and a splitter
self.bundle_combo_splitter = ToolbarBundle("example_combo", self.toolbar.components)
self.bundle_combo_splitter.add_action("example_combo")
# Add splitter; target the bare widget
self.bundle_combo_splitter.add_splitter(
name="splitter_example", target_widget=self.example_combo, min_width=100
)
# Add other bundles
self.toolbar.add_bundle(self.bundle_combo_splitter)
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))
self.toolbar.connect_bundle(
@@ -548,9 +502,7 @@ if __name__ == "__main__": # pragma: no cover
text_position="under",
),
)
# Show bundles - notice how performance and plot_export appear compactly after splitter!
self.toolbar.show_bundles(["example_combo", "performance", "plot_export"])
self.toolbar.show_bundles(["performance", "plot_export"])
self.toolbar.get_bundle("performance").add_action("save")
self.toolbar.get_bundle("performance").add_action("text")
self.toolbar.refresh()

View File

@@ -5,7 +5,7 @@ from typing import Literal, Mapping, Sequence
import slugify
from bec_lib import bec_logger
from qtpy.QtCore import Signal
from qtpy.QtCore import QTimer, Signal
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import (
QApplication,
@@ -31,8 +31,8 @@ from bec_widgets.utils.toolbars.actions import (
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.widget_state_manager import WidgetStateManager
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.dock_area.profile_utils import (
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
SETTINGS_KEYS,
default_profile_candidates,
delete_profile_files,
@@ -55,12 +55,14 @@ from bec_widgets.widgets.containers.dock_area.profile_utils import (
user_profile_candidates,
write_manifest,
)
from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import (
RestoreProfileDialog,
SaveProfileDialog,
)
from bec_widgets.widgets.containers.dock_area.settings.workspace_manager import WorkSpaceManager
from bec_widgets.widgets.containers.dock_area.toolbar_components.workspace_actions import (
from bec_widgets.widgets.containers.advanced_dock_area.settings.workspace_manager import (
WorkSpaceManager,
)
from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.workspace_actions import (
WorkspaceConnection,
workspace_bundle,
)
@@ -75,7 +77,7 @@ from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel import LogPanel
@@ -88,7 +90,7 @@ _PROFILE_NAMESPACE_UNSET = object()
PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")}
class BECDockArea(DockAreaWidget):
class AdvancedDockArea(DockAreaWidget):
RPC = True
PLUGIN = False
USER_ACCESS = [
@@ -244,10 +246,11 @@ class BECDockArea(DockAreaWidget):
if self._profile_exists("general", namespace):
init_profile = "general"
if init_profile:
self._load_initial_profile(init_profile)
# Defer initial load to the event loop so child widgets exist before state restore.
QTimer.singleShot(0, lambda: self._load_initial_profile(init_profile))
def _load_initial_profile(self, name: str) -> None:
"""Load the initial profile."""
"""Load the initial profile after construction when the event loop is running."""
self.load_profile(name, start_empty=self._start_empty)
combo = self.toolbar.components.get_action("workspace_combo").widget
combo.blockSignals(True)
@@ -1161,7 +1164,7 @@ if __name__ == "__main__": # pragma: no cover
dispatcher = BECDispatcher(gui_id="ads")
window = BECMainWindowNoRPC()
ads = BECDockArea(mode="creator", enable_profile_management=True, root_widget=True)
ads = AdvancedDockArea(mode="creator", enable_profile_management=True, root_widget=True)
window.setCentralWidget(ads)
window.show()

View File

@@ -6,7 +6,7 @@ from typing import Any, Callable, Literal, Mapping, Sequence, cast
from bec_lib import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import QByteArray, QSettings, QSize, Qt, QTimer
from qtpy.QtCore import QByteArray, QSettings, Qt, QTimer
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget
from shiboken6 import isValid
@@ -302,13 +302,6 @@ class DockAreaWidget(BECWidget, QWidget):
dock = CDockWidget(self.dock_manager, widget.objectName(), self)
dock.setWidget(widget)
widget_min_size = widget.minimumSize()
widget_min_hint = widget.minimumSizeHint()
dock_min_size = QSize(
max(widget_min_size.width(), widget_min_hint.width()),
max(widget_min_size.height(), widget_min_hint.height()),
)
dock.setMinimumSize(dock_min_size)
dock._dock_preferences = dict(dock_preferences or {})
dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetDeleteOnClose, True)
dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True)
@@ -331,9 +324,7 @@ class DockAreaWidget(BECWidget, QWidget):
if hasattr(widget, "widget_removed"):
widget.widget_removed.connect(on_widget_destroyed)
dock.setMinimumSizeHintMode(
CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidgetMinimumSize
)
dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget)
dock_area_widget = None
if tab_with is not None:
if not isValid(tab_with):
@@ -1198,7 +1189,8 @@ class DockAreaWidget(BECWidget, QWidget):
if button is not None:
button.setVisible(bool(visible))
apply()
# single shot to ensure dock is fully initialized, as widgets with their own dock manager can take a moment to initialize
QTimer.singleShot(0, apply)
def set_central_dock(self, dock: CDockWidget | QWidget | str) -> None:
"""
@@ -1310,7 +1302,11 @@ class DockAreaWidget(BECWidget, QWidget):
apply_widget_icon=apply_widget_icon,
)
self._create_dock_from_spec(spec)
def _on_name_established(_name: str) -> None:
# Defer creation so BECConnector sibling name enforcement has completed.
QTimer.singleShot(0, lambda: self._create_dock_from_spec(spec))
widget.name_established.connect(_on_name_established)
return widget
spec = self._build_creation_spec(
@@ -1415,7 +1411,7 @@ class DockAreaWidget(BECWidget, QWidget):
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QLabel, QMainWindow, QPushButton
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QPushButton
from bec_widgets.utils.colors import apply_theme

View File

@@ -28,7 +28,7 @@ from qtpy.QtWidgets import (
from bec_widgets import BECWidget, SafeSlot
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.containers.dock_area.profile_utils import (
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
get_profile_info,
is_quick_select,
list_profiles,

View File

@@ -10,7 +10,7 @@ from bec_widgets import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
from bec_widgets.widgets.containers.dock_area.profile_utils import list_quick_profiles
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import list_quick_profiles
class ProfileComboBox(QComboBox):

View File

@@ -7,7 +7,7 @@ from bec_lib.logger import bec_logger
from bec_lib.messages import ScanStatusMessage
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.containers.qt_ads import CDockWidget
@@ -37,7 +37,7 @@ class AutoUpdates(BECMainWindow):
):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.dock_area = BECDockArea(
self.dock_area = AdvancedDockArea(
parent=self,
object_name="dock_area",
enable_profile_management=False,

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
@@ -21,7 +22,6 @@ from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.status_bar import StatusToolBar
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
BECNotificationBroker,
@@ -54,7 +54,7 @@ class BECMainWindow(BECWidget, QMainWindow):
# Notification Centre overlay
self.notification_centre = NotificationCentre(parent=self) # Notification layer
self.notification_broker = BECNotificationBroker(parent=self)
self.notification_broker = BECNotificationBroker()
self._nc_margin = 16
self._position_notification_centre()
@@ -115,11 +115,14 @@ class BECMainWindow(BECWidget, QMainWindow):
Prepare the BEC specific widgets in the status bar.
"""
# Left: Beamline condition status toolbar (auto-fetches all conditions)
self._status_toolbar = StatusToolBar(parent=self, names=None)
self.status_bar.addWidget(self._status_toolbar)
# Left: AppID label
self._app_id_label = QLabel()
self._app_id_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
self.status_bar.addWidget(self._app_id_label)
# Add a separator after the status toolbar
# Add a separator after the app ID label
self._add_separator()
# Centre: Clientinfo label (stretch=1 so it expands)
@@ -338,27 +341,13 @@ class BECMainWindow(BECWidget, QMainWindow):
help_menu.addAction(bec_docs)
help_menu.addAction(widgets_docs)
help_menu.addAction(bug_report)
help_menu.addSeparator()
self._app_id_action = QAction(self)
self._app_id_action.triggered.connect(self._copy_app_id_to_clipboard)
help_menu.addAction(self._app_id_action)
def _copy_app_id_to_clipboard(self):
"""
Copy the app ID to the clipboard.
"""
if self.bec_dispatcher.cli_server is not None:
server_id = self.bec_dispatcher.cli_server.gui_id
clipboard = QApplication.clipboard()
clipboard.setText(server_id)
################################################################################
# Status Bar Addons
################################################################################
def display_app_id(self):
"""
Display the app ID in the Help menu.
Display the app ID in the status bar.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
@@ -366,8 +355,7 @@ class BECMainWindow(BECWidget, QMainWindow):
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
if hasattr(self, "_app_id_action"):
self._app_id_action.setText(status_message)
self._app_id_label.setText(status_message)
@SafeSlot(dict, dict)
def display_client_message(self, msg: dict, meta: dict):

View File

@@ -7,7 +7,7 @@ import os
from bec_lib.device import Positioner
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Qt, Signal
from qtpy.QtCore import Signal
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox
@@ -66,13 +66,6 @@ class PositionerBox(PositionerBoxBase):
self.addWidget(self.ui)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter)
ui_min_size = self.ui.minimumSize()
ui_min_hint = self.ui.minimumSizeHint()
self.setMinimumSize(
max(ui_min_size.width(), ui_min_hint.width()),
max(ui_min_size.height(), ui_min_hint.height()),
)
# fix the size of the device box
db = self.ui.device_box

View File

@@ -32,7 +32,6 @@ class DeviceInputConfig(ConnectionConfig):
default: str | None = None
arg_name: str | None = None
apply_filter: bool = True
signal_class_filter: list[str] = []
@field_validator("device_filter")
@classmethod
@@ -126,13 +125,11 @@ class DeviceInputBase(BECWidget):
current_device = WidgetIO.get_value(widget=self, as_string=True)
self.config.device_filter = self.device_filter
self.config.readout_filter = self.readout_filter
self.config.signal_class_filter = self.signal_class_filter
if self.apply_filter is False:
return
all_dev = self.dev.enabled_devices
devs = self._filter_devices_by_signal_class(all_dev)
# Filter based on device class
devs = [dev for dev in devs if self._check_device_filter(dev)]
devs = [dev for dev in all_dev if self._check_device_filter(dev)]
# Filter based on readout priority
devs = [dev for dev in devs if self._check_readout_filter(dev)]
self.devices = [device.name for device in devs]
@@ -193,27 +190,6 @@ class DeviceInputBase(BECWidget):
self.config.apply_filter = value
self.update_devices_from_filters()
@SafeProperty("QStringList")
def signal_class_filter(self) -> list[str]:
"""
Get the signal class filter for devices.
Returns:
list[str]: List of signal class names used for filtering devices.
"""
return self.config.signal_class_filter
@signal_class_filter.setter
def signal_class_filter(self, value: list[str] | None):
"""
Set the signal class filter and update the device list.
Args:
value (list[str] | None): List of signal class names to filter by.
"""
self.config.signal_class_filter = value or []
self.update_devices_from_filters()
@SafeProperty(bool)
def filter_to_device(self):
"""Include devices in filters."""
@@ -403,20 +379,6 @@ class DeviceInputBase(BECWidget):
"""
return all(isinstance(device, self._device_handler[entry]) for entry in self.device_filter)
def _filter_devices_by_signal_class(
self, devices: list[Device | BECSignal | ComputedSignal | Positioner]
) -> list[Device | BECSignal | ComputedSignal | Positioner]:
"""Filter devices by signal class, if a signal class filter is set."""
if not self.config.signal_class_filter:
return devices
if not self.client or not hasattr(self.client, "device_manager"):
return []
signals = FilterIO.update_with_signal_class(
widget=self, signal_class_filter=self.config.signal_class_filter, client=self.client
)
allowed_devices = {device_name for device_name, _, _ in signals}
return [dev for dev in devices if dev.name in allowed_devices]
def _check_readout_filter(
self, device: Device | BECSignal | ComputedSignal | Positioner
) -> bool:

View File

@@ -6,7 +6,7 @@ from qtpy.QtCore import Property
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.filter_io import FilterIO, LineEditFilterHandler
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.utils.widget_io import WidgetIO
@@ -17,8 +17,6 @@ class DeviceSignalInputBaseConfig(ConnectionConfig):
"""Configuration class for DeviceSignalInputBase."""
signal_filter: str | list[str] | None = None
signal_class_filter: list[str] | None = None
ndim_filter: int | list[int] | None = None
default: str | None = None
arg_name: str | None = None
device: str | None = None

View File

@@ -1,6 +1,7 @@
from bec_lib.callback_handler import EventType
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import QSize, Signal, Slot
from qtpy.QtGui import QPainter, QPaintEvent, QPen
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.colors import get_accent_colors
@@ -26,12 +27,12 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
signal_class_filter: List of signal classes to filter the devices by. Only devices with signals of these classes will be shown.
"""
USER_ACCESS = ["set_device", "devices"]
ICON_NAME = "list_alt"
PLUGIN = True
RPC = False
device_selected = Signal(str)
device_reset = Signal()
@@ -50,7 +51,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
available_devices: list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
signal_class_filter: list[str] | None = None,
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
@@ -63,7 +63,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self._is_valid_input = False
self._accent_colors = get_accent_colors()
self._set_first_element_as_empty = False
# We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed.
@@ -86,10 +85,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
# Device filter default is None
if device_filter is not None:
self.set_device_filter(device_filter)
if signal_class_filter is not None:
self.signal_class_filter = signal_class_filter
# Set default device if passed
if default is not None:
self.set_device(default)
@@ -186,70 +181,21 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
device = self.itemData(idx)[0] # type: ignore[assignment]
return super().validate_device(device)
@property
def is_valid_input(self) -> bool:
"""Whether the current text represents a valid device selection."""
return self._is_valid_input
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QHBoxLayout,
QLabel,
QLineEdit,
QVBoxLayout,
QWidget,
)
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.colors import apply_theme
app = QApplication([])
apply_theme("dark")
widget = QWidget()
widget.setWindowTitle("DeviceComboBox demo")
layout = QVBoxLayout(widget)
layout.addWidget(QLabel("Device filter controls"))
controls = QHBoxLayout()
layout.addLayout(controls)
class_input = QLineEdit()
class_input.setPlaceholderText("signal_class_filter (comma-separated), e.g. AsyncSignal")
controls.addWidget(class_input)
filter_device = QCheckBox("Device")
filter_positioner = QCheckBox("Positioner")
filter_signal = QCheckBox("Signal")
filter_computed = QCheckBox("ComputedSignal")
controls.addWidget(filter_device)
controls.addWidget(filter_positioner)
controls.addWidget(filter_signal)
controls.addWidget(filter_computed)
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
combo = DeviceComboBox()
combo.set_first_element_as_empty = True
combo.devices = ["samx", "dev1", "dev2", "dev3", "dev4"]
layout.addWidget(combo)
def _apply_filters():
raw = class_input.text().strip()
if raw:
combo.signal_class_filter = [entry.strip() for entry in raw.split(",") if entry.strip()]
else:
combo.signal_class_filter = []
combo.filter_to_device = filter_device.isChecked()
combo.filter_to_positioner = filter_positioner.isChecked()
combo.filter_to_signal = filter_signal.isChecked()
combo.filter_to_computed_signal = filter_computed.isChecked()
class_input.textChanged.connect(_apply_filters)
filter_device.toggled.connect(_apply_filters)
filter_positioner.toggled.connect(_apply_filters)
filter_signal.toggled.connect(_apply_filters)
filter_computed.toggled.connect(_apply_filters)
_apply_filters()
widget.show()
app.exec_()

View File

@@ -31,11 +31,12 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
USER_ACCESS = ["set_device", "devices", "_is_valid_input"]
device_selected = Signal(str)
device_config_update = Signal()
PLUGIN = True
RPC = False
ICON_NAME = "edit_note"
def __init__(

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from qtpy.QtCore import QSize, Qt, Signal
from bec_lib.device import Positioner
from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
@@ -21,27 +22,18 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device: Device name to filter signals from.
signal_filter: Signal filter, list of signal kinds from ophyd Kind enum. Check DeviceSignalInputBase for more details.
signal_class_filter: List of signal classes to filter the signals by. Only signals of these classes will be shown.
ndim_filter: Dimensionality filter, int or list of ints to filter signals by their number of dimensions. If signal do not support ndim, it will be included in the selection anyway.
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
store_signal_config: Whether to store the full signal config in the combobox item data.
require_device: If True, signals are only shown/validated when a device is set.
Signals:
device_signal_changed: Emitted when the current text represents a valid signal selection.
signal_reset: Emitted when validation fails and the selection should be treated as cleared.
"""
USER_ACCESS = ["set_signal", "set_device", "signals", "get_signal_name"]
ICON_NAME = "list_alt"
PLUGIN = True
RPC = False
RPC = True
device_signal_changed = Signal(str)
signal_reset = Signal()
def __init__(
self,
@@ -50,13 +42,9 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
config: DeviceSignalInputBaseConfig | None = None,
gui_id: str | None = None,
device: str | None = None,
signal_filter: list[Kind] | None = None,
signal_class_filter: list[str] | None = None,
ndim_filter: int | list[int] | None = None,
signal_filter: str | list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
store_signal_config: bool = True,
require_device: bool = False,
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
@@ -69,64 +57,26 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.setMinimumSize(QSize(100, 0))
self._set_first_element_as_empty = True
self._signal_class_filter = signal_class_filter or []
self._store_signal_config = store_signal_config
self.config.ndim_filter = ndim_filter or None
self._require_device = require_device
self._is_valid_input = False
# Note: Runtime arguments (e.g. device, default, arg_name) intentionally take
# precedence over values from the passed-in config. Full reconciliation and
# restoration of state between designer-provided config and runtime arguments
# is not yet implemented, as earlier attempts caused issues with QtDesigner.
# We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed.
self.currentTextChanged.connect(self.on_text_changed)
# Kind filtering is always applied; class filtering is additive. If signal_filter is None,
# we default to hinted+normal, even when signal_class_filter is empty or None. To disable
# kinds, pass an explicit signal_filter or toggle include_* after init.
if signal_filter is not None:
self.set_filter(signal_filter)
else:
self.set_filter([Kind.hinted, Kind.normal, Kind.config])
if device is not None:
self.set_device(device)
if default is not None:
self.set_signal(default)
@SafeSlot(str)
def set_device(self, device: str | None):
"""
Set the device. When signal_class_filter is active, ensures base-class
logic runs and then refreshes the signal list to show only signals from
that device matching the signal class filter.
Args:
device(str): device name.
"""
super().set_device(device)
if self._signal_class_filter:
# Refresh the signal list to show only this device's signals
self.update_signals_from_signal_classes()
@SafeSlot()
@SafeSlot(dict, dict)
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):
"""Update the filters for the combobox.
When signal_class_filter is active, skip the normal Kind-based filtering.
Args:
content (dict | None): Content dictionary from BEC event.
metadata (dict | None): Metadata dictionary from BEC event.
"""
"""Update the filters for the combobox"""
super().update_signals_from_filters(content, metadata)
if self._signal_class_filter:
self.update_signals_from_signal_classes()
return
# pylint: disable=protected-access
if FilterIO._find_handler(self) is ComboBoxFilterHandler:
if len(self._config_signals) > 0:
@@ -168,63 +118,6 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
if self.count() > 0 and self.itemText(0) == "":
self.removeItem(0)
@SafeProperty("QStringList")
def signal_class_filter(self) -> list[str]:
"""
Get the list of signal classes to filter.
Returns:
list[str]: List of signal class names to filter.
"""
return self._signal_class_filter
@signal_class_filter.setter
def signal_class_filter(self, value: list[str] | None):
"""
Set the signal class filter.
Args:
value (list[str] | None): List of signal class names to filter, or None/empty
to disable class-based filtering and revert to the default behavior.
"""
normalized_value = value or []
self._signal_class_filter = normalized_value
self.config.signal_class_filter = normalized_value
if self._signal_class_filter:
self.update_signals_from_signal_classes()
else:
self.update_signals_from_filters()
@SafeProperty(int)
def ndim_filter(self) -> int:
"""Dimensionality filter for signals."""
return self.config.ndim_filter if isinstance(self.config.ndim_filter, int) else -1
@ndim_filter.setter
def ndim_filter(self, value: int):
self.config.ndim_filter = None if value < 0 else value
if self._signal_class_filter:
self.update_signals_from_signal_classes(ndim_filter=self.config.ndim_filter)
@SafeProperty(bool)
def require_device(self) -> bool:
"""
If True, signals are only shown/validated when a device is set.
Note:
This property affects list rebuilding only when a signal_class_filter
is active. Without a signal class filter, the available signals are
managed by the standard Kind-based filtering.
"""
return self._require_device
@require_device.setter
def require_device(self, value: bool):
self._require_device = value
# Rebuild list when toggled, but only when using signal_class_filter
if self._signal_class_filter:
self.update_signals_from_signal_classes()
def set_to_obj_name(self, obj_name: str) -> bool:
"""
Set the combobox to the object name of the signal.
@@ -273,91 +166,6 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
return signal_name if signal_name else ""
def get_signal_config(self) -> dict | None:
"""
Get the signal config from the combobox for the currently selected signal.
Returns:
dict | None: The signal configuration dictionary or None if not available.
"""
if not self._store_signal_config:
return None
index = self.currentIndex()
if index == -1:
return None
signal_info = self.itemData(index)
return signal_info if signal_info else None
def update_signals_from_signal_classes(self, ndim_filter: int | list[int] | None = None):
"""
Update the combobox with signals filtered by signal classes and optionally by ndim.
Uses device_manager.get_bec_signals() to retrieve signals.
If a device is set, only shows signals from that device.
Args:
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
If provided, only signals with matching ndim will be included.
Can be a single int or a list of ints. Use None to include all dimensions.
If not provided, uses the previously set ndim_filter.
"""
if not self._signal_class_filter:
return
if self._require_device and not self._device:
self.clear()
self._signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return
# Update stored ndim_filter if a new one is provided
if ndim_filter is not None:
self.config.ndim_filter = ndim_filter
self.clear()
# Get signals with ndim filtering applied at the FilterIO level
signals = FilterIO.update_with_signal_class(
widget=self,
signal_class_filter=self._signal_class_filter,
client=self.client,
ndim_filter=self.config.ndim_filter, # Pass ndim_filter to FilterIO
)
# Track signals for validation and FilterIO selection
self._signals = []
for device_name, signal_name, signal_config in signals:
# Filter by device if one is set
if self._device and device_name != self._device:
continue
if self._signal_filter:
kind_str = signal_config.get("kind_str")
if kind_str is not None and kind_str not in {
kind.name for kind in self._signal_filter
}:
continue
# Get storage_name for tooltip
storage_name = signal_config.get("storage_name", "")
# Store the full signal config as item data if requested
if self._store_signal_config:
self.addItem(signal_name, signal_config)
else:
self.addItem(signal_name)
# Track for validation
self._signals.append(signal_name)
# Set tooltip to storage_name (Qt.ToolTipRole = 3)
if storage_name:
self.setItemData(self.count() - 1, storage_name, Qt.ItemDataRole.ToolTipRole)
# Keep FilterIO selection in sync for validate_signal
FilterIO.set_selection(widget=self, selection=self._signals)
@SafeSlot()
def reset_selection(self):
"""Reset the selection of the combobox."""
@@ -368,44 +176,22 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
@SafeSlot(str)
def on_text_changed(self, text: str):
"""Validate and emit only when the signal is valid.
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
For a positioner, the readback value has to be renamed to the device name.
When using signal_class_filter, device validation is skipped.
Args:
text (str): Text in the combobox.
"""
self.check_validity(text)
def check_validity(self, input_text: str) -> None:
"""Check if the current value is a valid signal and emit only when valid."""
if self._signal_class_filter:
if self._require_device and (not self._device or not input_text):
is_valid = False
else:
is_valid = self.validate_signal(input_text)
else:
if self._require_device and not self.validate_device(self._device):
is_valid = False
else:
is_valid = self.validate_device(self._device) and self.validate_signal(input_text)
if is_valid:
self._is_valid_input = True
self.device_signal_changed.emit(input_text)
self.setStyleSheet("border: 1px solid transparent;")
else:
self._is_valid_input = False
self.signal_reset.emit()
if self.isEnabled():
self.setStyleSheet("border: 1px solid red;")
if self.validate_device(self.device) is False:
return
if self.validate_signal(text) is False:
return
self.device_signal_changed.emit(text)
@property
def selected_signal_comp_name(self) -> str:
return dict(self.signals).get(self.currentText(), {}).get("component_name", "")
@property
def is_valid_input(self) -> bool:
"""Whether the current text represents a valid signal selection."""
return self._is_valid_input
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
@@ -419,14 +205,7 @@ if __name__ == "__main__": # pragma: no cover
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
box = SignalComboBox(
device="waveform",
signal_class_filter=["AsyncSignal", "AsyncMultiSignal"],
ndim_filter=[1, 2],
store_signal_config=True,
signal_filter=[Kind.hinted, Kind.normal, Kind.config],
) # change signal filter class to test
box.setEditable(True)
box = SignalComboBox(device="samx")
layout.addWidget(box)
widget.show()
app.exec_()

View File

@@ -29,7 +29,7 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
device_signal_changed = Signal(str)
PLUGIN = True
RPC = False
RPC = True
ICON_NAME = "vital_signs"
def __init__(

View File

@@ -1,7 +1,7 @@
"""Module for the device configuration form widget for EpicsMotor, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV"""
from copy import deepcopy
from typing import Any, Type
from typing import Type
from bec_lib.atlas_models import Device as DeviceModel
from bec_lib.logger import bec_logger
@@ -191,7 +191,7 @@ class DeviceConfigTemplate(QtWidgets.QWidget):
if widget is not None:
self._set_value_for_widget(widget, value)
def _set_value_for_widget(self, widget: QtWidgets.QWidget, value: Any) -> None:
def _set_value_for_widget(self, widget: QtWidgets.QWidget, value: any) -> None:
"""
Set the value for a widget based on its type.

View File

@@ -1,7 +1,7 @@
"""Module for custom input widgets used in device configuration templates."""
from ast import literal_eval
from typing import Any, Callable
from typing import Callable
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
@@ -15,7 +15,7 @@ from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
logger = bec_logger.logger
def _try_literal_eval(value: str) -> Any:
def _try_literal_eval(value: any) -> any:
"""Consolidated function for literal evaluation of a value."""
if value in ["true", "True"]:
return True
@@ -407,7 +407,7 @@ class DeviceConfigField(BaseModel):
static: bool = False
placeholder_text: str | None = None
validation_callback: list[Callable[[str], bool]] | None = None
default: Any = None
default: any = None
model_config = ConfigDict(arbitrary_types_allowed=True)

View File

@@ -5,12 +5,10 @@ in DeviceTableRow entries.
from __future__ import annotations
import traceback
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, Tuple
from typing import Any, Callable, Iterable, Tuple
from bec_lib.atlas_models import Device as DeviceModel
from bec_lib.callback_handler import EventType
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtGui, QtWidgets
@@ -28,9 +26,6 @@ from bec_widgets.widgets.control.device_manager.components.ophyd_validation impo
get_validation_icons,
)
if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import ConfigAction
logger = bec_logger.logger
_DeviceCfgIter = Iterable[dict[str, Any]]
@@ -213,11 +208,6 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
# Signal emitted when the device config is in sync with Redis
device_config_in_sync_with_redis = QtCore.Signal(bool)
# Request multiple validation updates for devices
request_update_multiple_device_validations = QtCore.Signal(list)
# Request update after client DEVICE_UPDATE event
request_update_after_client_device_update = QtCore.Signal()
_auto_size_request = QtCore.Signal()
def __init__(self, parent: QtWidgets.QWidget | None = None, client=None):
@@ -277,66 +267,15 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
# Connect slots
self.table.selectionModel().selectionChanged.connect(self._on_selection_changed)
self.table.cellDoubleClicked.connect(self._on_cell_double_clicked)
self.request_update_multiple_device_validations.connect(
self.update_multiple_device_validations
)
self.request_update_after_client_device_update.connect(self._on_device_config_update)
# Install event filter
self.table.installEventFilter(self)
# Add hook to BECClient for DeviceUpdates
self.client_callback_id = self.client.callbacks.register(
event_type=EventType.DEVICE_UPDATE, callback=self.__on_client_device_update_event
)
def cleanup(self):
"""Cleanup resources."""
self.row_data.clear() # Drop references to row data..
self.client.callbacks.remove(self.client_callback_id) # Unregister callback
# self._autosize_timer.stop()
super().cleanup()
def __on_client_device_update_event(
self, action: "ConfigAction", config: dict[str, dict[str, Any]]
) -> None:
"""Handle DEVICE_UPDATE events from the BECClient."""
self.request_update_after_client_device_update.emit()
@SafeSlot()
def _on_device_config_update(self) -> None:
"""Handle device configuration updates from the BECClient."""
# Determine the overlapping device configs between Redis and the table
device_config_overlap_with_bec = self._get_overlapping_configs()
if len(device_config_overlap_with_bec) > 0:
# Notify any listeners about the update, the device manager devices will now be up to date
self.device_configs_changed.emit(device_config_overlap_with_bec, True, True)
# Correct all connection statuses in the table which are ConnectionStatus.CONNECTED
# to ConnectionStatus.CAN_CONNECT
device_status_updates = []
validation_results = self.get_validation_results()
for device_name, (cfg, config_status, connection_status) in validation_results.items():
if device_name is None:
continue
# Check if config is not in the overlap, but connection status is CONNECTED
# Update to CAN_CONNECT
if cfg not in device_config_overlap_with_bec:
if connection_status == ConnectionStatus.CONNECTED.value:
device_status_updates.append(
(cfg, config_status, ConnectionStatus.CAN_CONNECT.value, "")
)
# Update only if there are any updates
if len(device_status_updates) > 0:
# NOTE We need to emit here a signal to call update_multiple_device_validations
# as this otherwise can cause problems with being executed from a python callback
# thread which are not properly scheduled in the Qt event loop. We see that this
# has caused issues in form of segfaults under certain usage of the UI. Please
# do not remove this signal & slot mechanism!
self.request_update_multiple_device_validations.emit(device_status_updates)
# Check if in sync with BEC server session
in_sync_with_redis = self._is_config_in_sync_with_redis()
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
# -------------------------------------------------------------------------
# Custom hooks for table events
# -------------------------------------------------------------------------
@@ -830,51 +769,6 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
logger.error(f"Error comparing device configs: {e}")
return False
def _get_overlapping_configs(self) -> list[dict[str, Any]]:
"""
Get the device configs that overlap between the table and the config in the current running BEC session.
A device will be ignored if it is disabled in the BEC session.
Args:
device_configs (Iterable[dict[str, Any]]): The device configs to check.
Returns:
list[dict[str, Any]]: The list of overlapping device configs.
"""
overlapping_configs = []
for cfg in self.get_device_config():
device_name = cfg.get("name", None)
if device_name is None:
continue
if self._is_device_in_redis_session(device_name, cfg):
overlapping_configs.append(cfg)
return overlapping_configs
def _is_device_in_redis_session(self, device_name: str, device_config: dict) -> bool:
"""Check if a device is in the running section."""
dev_obj = self.client.device_manager.devices.get(device_name, None)
if dev_obj is None or dev_obj.enabled is False:
return False
return self._compare_device_configs(dev_obj._config, device_config)
def _compare_device_configs(self, config1: dict, config2: dict) -> bool:
"""Compare two device configurations through the Device model in bec_lib.atlas_models.
Args:
config1 (dict): The first device configuration.
config2 (dict): The second device configuration.
Returns:
bool: True if the configurations are equivalent, False otherwise.
"""
try:
model1 = DeviceModel.model_validate(config1)
model2 = DeviceModel.model_validate(config2)
return model1 == model2
except Exception:
return False
# -------------------------------------------------------------------------
# Public API to manage device configs in the table
# -------------------------------------------------------------------------
@@ -938,7 +832,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
device_configs (Iterable[dict[str, Any]]): The device configs to set.
skip_validation (bool): Whether to skip validation for the set devices.
"""
self.set_busy(True)
self.set_busy(True, text="Loading device configurations...")
with self.table_sort_on_hold:
self.clear_device_configs()
cfgs_added = []
@@ -948,12 +842,12 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
self.device_configs_changed.emit(cfgs_added, True, skip_validation)
in_sync_with_redis = self._is_config_in_sync_with_redis()
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
self.set_busy(False)
self.set_busy(False, text="")
@SafeSlot()
def clear_device_configs(self):
"""Clear the device configs. Skips validation by default."""
self.set_busy(True)
"""Clear the device configs. Skips validation per default."""
self.set_busy(True, text="Clearing device configurations...")
device_configs = self.get_device_config()
with self.table_sort_on_hold:
self._clear_table()
@@ -962,7 +856,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
) # Skip validation for removals
in_sync_with_redis = self._is_config_in_sync_with_redis()
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
self.set_busy(False)
self.set_busy(False, text="")
@SafeSlot(list, bool)
def add_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False):
@@ -975,7 +869,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
device_configs (Iterable[dict[str, Any]]): The device configs to add.
skip_validation (bool): Whether to skip validation for the added devices.
"""
self.set_busy(True)
self.set_busy(True, text="Adding device configurations...")
already_in_table = []
not_in_table = []
with self.table_sort_on_hold:
@@ -1000,7 +894,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
self.device_configs_changed.emit(already_in_table + not_in_table, True, skip_validation)
in_sync_with_redis = self._is_config_in_sync_with_redis()
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
self.set_busy(False)
self.set_busy(False, text="")
@SafeSlot(list, bool)
def update_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False):
@@ -1011,7 +905,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
device_configs (Iterable[dict[str, Any]]): The device configs to update.
skip_validation (bool): Whether to skip validation for the updated devices.
"""
self.set_busy(True)
self.set_busy(True, text="Loading device configurations...")
cfgs_updated = []
with self.table_sort_on_hold:
for cfg in device_configs:
@@ -1026,7 +920,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
self.device_configs_changed.emit(cfgs_updated, True, skip_validation)
in_sync_with_redis = self._is_config_in_sync_with_redis()
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
self.set_busy(False)
self.set_busy(False, text="")
@SafeSlot(list)
def remove_device_configs(self, device_configs: _DeviceCfgIter):
@@ -1036,7 +930,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
Args:
device_configs (dict[str, dict]): The device configs to remove.
"""
self.set_busy(True)
self.set_busy(True, text="Removing device configurations...")
cfgs_to_be_removed = list(device_configs)
with self.table_sort_on_hold:
self._remove_rows_by_name([cfg["name"] for cfg in cfgs_to_be_removed])
@@ -1045,7 +939,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
) # Skip validation for removals
in_sync_with_redis = self._is_config_in_sync_with_redis()
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
self.set_busy(False)
self.set_busy(False, text="")
@SafeSlot(str)
def remove_device(self, device_name: str):
@@ -1055,11 +949,11 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
Args:
device_name (str): The name of the device to remove.
"""
self.set_busy(True)
self.set_busy(True, text=f"Removing device configuration for {device_name}...")
row_data = self.row_data.get(device_name)
if not row_data:
logger.warning(f"Device {device_name} not found in table for removal.")
self.set_busy(False)
self.set_busy(False, text="")
return
with self.table_sort_on_hold:
self._remove_rows_by_name([row_data.data["name"]])
@@ -1067,7 +961,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
self.device_configs_changed.emit(cfgs, False, True) # Skip validation for removals
in_sync_with_redis = self._is_config_in_sync_with_redis()
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
self.set_busy(False)
self.set_busy(False, text="")
@SafeSlot(list)
def update_multiple_device_validations(self, validation_results: _ValidationResultIter):
@@ -1079,15 +973,9 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
Args:
device_configs (Iterable[dict[str, Any]]): The device configs to update.
"""
self.set_busy(True)
self.set_busy(True, text="Updating device validations in session...")
self.table.setSortingEnabled(False)
logger.info(
f"Updating multiple device validation statuses with names {[cfg.get('name', '') for cfg, _, _, _ in validation_results]}..."
)
for cfg, config_status, connection_status, _ in validation_results:
logger.info(
f"Updating device {cfg.get('name', '')} with config status {config_status} and connection status {connection_status}..."
)
row = self._find_row_by_name(cfg.get("name", ""))
if row is None:
logger.warning(f"Device {cfg.get('name')} not found in table for session update.")
@@ -1096,7 +984,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
in_sync_with_redis = self._is_config_in_sync_with_redis()
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
self.table.setSortingEnabled(True)
self.set_busy(False)
self.set_busy(False, text="")
@SafeSlot(dict, int, int, str)
def update_device_validation(
@@ -1109,13 +997,13 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
Args:
"""
self.set_busy(True)
self.set_busy(True, text="Updating device validation status...")
row = self._find_row_by_name(device_config.get("name", ""))
if row is None:
logger.warning(
f"Device {device_config.get('name')} not found in table for validation update."
)
self.set_busy(False)
self.set_busy(False, text="")
return
# Disable here sorting without context manager to avoid triggering of registered
# resizing methods. Those can be quite heavy, thus, should not run on every
@@ -1125,4 +1013,4 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
self.table.setSortingEnabled(True)
in_sync_with_redis = self._is_config_in_sync_with_redis()
self.device_config_in_sync_with_redis.emit(in_sync_with_redis)
self.set_busy(False)
self.set_busy(False, text="")

View File

@@ -69,12 +69,11 @@ class DeviceTest(QtCore.QRunnable):
enable_connect: bool,
force_connect: bool,
timeout: float,
device_manager_ds: object | None = None,
):
super().__init__()
self.uuid = device_model.uuid
test_config = {device_model.device_name: device_model.device_config}
self.tester = StaticDeviceTest(config_dict=test_config, device_manager_ds=device_manager_ds)
self.tester = StaticDeviceTest(config_dict=test_config)
self.signals = DeviceTestResult()
self.device_config = device_model.device_config
self.enable_connect = enable_connect
@@ -265,6 +264,7 @@ class LegendLabel(QtWidgets.QWidget):
icon = self._icons["config_status"][status]
icon_widget = ValidationButton(parent=self, icon=icon)
icon_widget.setEnabled(False)
icon_widget.set_enabled_style(False)
icon_widget.setToolTip(f"Device Configuration: {status.description()}")
layout.addWidget(icon_widget, 0, ii + 1)
@@ -282,6 +282,7 @@ class LegendLabel(QtWidgets.QWidget):
icon = self._icons["connection_status"][status]
icon_widget = ValidationButton(parent=self, icon=icon)
icon_widget.setEnabled(False)
icon_widget.set_enabled_style(False)
icon_widget.setToolTip(f"Connection Status: {status.description()}")
layout.addWidget(icon_widget, 1, ii + 1)
layout.setColumnStretch(layout.columnCount(), 1) # Counts as a column
@@ -547,10 +548,9 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
if device_name is None: # Config missing name, will be skipped..
logger.error(f"Device config missing 'name': {cfg}. Config will be skipped.")
continue
if not added: # Remove requested, holds priority over skip_validation
if not added or skip_validation is True: # Remove requested
self._remove_device_config(cfg)
continue
# Check if device is already in running session with the same config
if self._is_device_in_redis_session(cfg.get("name"), cfg):
logger.debug(
f"Device {device_name} already in running session with same config. Skipping."
@@ -563,39 +563,29 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
"Device already in session.",
)
)
# If in addition, the device is to be kept visible after validation, we ensure it is added
# and potentially update it's config & validation icons
if device_name in self._keep_visible_after_validation:
if not self._device_already_exists(device_name):
self._add_device_config(
cfg,
connect=connect,
force_connect=force_connect,
timeout=timeout,
skip_validation=True,
)
# Now make sure that the existing widget is updated to reflect the CONNECTED & VALID status
widget: ValidationListItem = self.list_widget.get_widget(device_name)
if widget:
self._on_device_test_completed(
cfg,
ConfigStatus.VALID.value,
ConnectionStatus.CONNECTED.value,
"Device already in session.",
)
else: # If not to be kept visible, we ensure it is removed from the list
self._remove_device_config(cfg)
continue # Now we continue to the next device config
if skip_validation is True: # Skip validation requested, so we skip this
self._add_device_config(
cfg,
connect=connect,
force_connect=force_connect,
timeout=timeout,
skip_validation=True,
)
self._on_device_test_completed(
cfg,
ConfigStatus.VALID.value,
ConnectionStatus.CONNECTED.value,
"Device already in session.",
)
self._remove_device_config(cfg)
continue
# New device case, that is not in BEC session
if not self._device_already_exists(cfg.get("name")):
if not self._device_already_exists(cfg.get("name")): # New device case
self._add_device_config(
cfg, connect=connect, force_connect=force_connect, timeout=timeout
)
else: # Update existing, but removing first
logger.info(f"Device {cfg.get('name')} already exists, re-adding it.")
self._remove_device_config(cfg, force_remove=True)
self._remove_device_config(cfg)
self._add_device_config(
cfg, connect=connect, force_connect=force_connect, timeout=timeout
)
@@ -671,13 +661,13 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
if not skip_validation:
self.__delayed_submit_test(widget, connect, force_connect, timeout)
def _remove_device(self, device_name: str, force_remove: bool = False) -> None:
def _remove_device(self, device_name: str) -> None:
if not self._device_already_exists(device_name):
logger.debug(
f"Device with name {device_name} not found in OphydValidation, can't remove it."
)
return
if device_name in self._keep_visible_after_validation and not force_remove:
if device_name in self._keep_visible_after_validation:
logger.debug(
f"Device with name {device_name} is set to be kept visible after validation, not removing it."
)
@@ -686,11 +676,9 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
self.thread_pool_manager.clear_device_in_queue(device_name)
self.list_widget.remove_widget_item(device_name)
def _remove_device_config(
self, device_config: dict[str, Any], force_remove: bool = False
) -> None:
def _remove_device_config(self, device_config: dict[str, Any]) -> None:
device_name = device_config.get("name")
self._remove_device(device_name, force_remove=force_remove)
self._remove_device(device_name)
@SafeSlot(str, dict, bool, bool, float)
def _on_request_rerun_validation(
@@ -753,15 +741,11 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
# Remove widget from list as it's safe to assume it can be loaded.
self._remove_device_config(widget.device_model.device_config)
return
dm_ds = None
if self.client:
dm_ds = getattr(self.client, "device_manager", None)
runnable = DeviceTest(
device_model=widget.device_model,
enable_connect=connect,
force_connect=force_connect,
timeout=timeout,
device_manager_ds=dm_ds,
)
widget.validation_scheduled()
if self.thread_pool_manager:

View File

@@ -31,14 +31,27 @@ class ValidationButton(QtWidgets.QPushButton):
self, parent: QtWidgets.QWidget | None = None, icon: QtGui.QIcon | None = None
) -> None:
super().__init__(parent=parent)
self.transparent_style = "background-color: transparent; border: none;"
if icon:
self.setIcon(icon)
self.setFlat(True)
self.setEnabled(True)
def setEnabled(self, enabled: bool) -> None:
self.set_enabled_style(enabled)
return super().setEnabled(enabled)
def set_enabled_style(self, enabled: bool) -> None:
"""Set the enabled state of the button with style update.
Args:
enabled (bool): Whether the button should be enabled.
"""
if enabled:
self.setStyleSheet("")
else:
self.setStyleSheet(self.transparent_style)
class ValidationDialog(QtWidgets.QDialog):
"""
@@ -295,11 +308,13 @@ class ValidationListItem(QtWidgets.QWidget):
# Enable/disable buttons based on status
config_but_en = config_status in [ConfigStatus.UNKNOWN, ConfigStatus.INVALID]
self.status_button.setEnabled(config_but_en)
self.status_button.set_enabled_style(config_but_en)
connect_but_en = connection_status in [
ConnectionStatus.UNKNOWN,
ConnectionStatus.CANNOT_CONNECT,
]
self.connection_button.setEnabled(connect_but_en)
self.connection_button.set_enabled_style(connect_but_en)
@SafeSlot()
def validation_scheduled(self):
@@ -308,7 +323,9 @@ class ValidationListItem(QtWidgets.QWidget):
"Validation scheduled...", ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN
)
self.status_button.setEnabled(False)
self.status_button.set_enabled_style(False)
self.connection_button.setEnabled(False)
self.connection_button.set_enabled_style(False)
self._spinner.setVisible(True)
@SafeSlot()

View File

@@ -9,7 +9,7 @@ from bec_lib.macro_update_handler import has_executable_code
from qtpy.QtCore import QEvent, QTimer, Signal
from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QWidget
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.qt_ads import CDockAreaWidget, CDockWidget
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget

View File

@@ -6,11 +6,9 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar_plugin import (
DeviceInitializationProgressBarPlugin,
)
from bec_widgets.widgets.editors.vscode.vs_code_editor_plugin import VSCodeEditorPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceInitializationProgressBarPlugin())
QPyDesignerCustomWidgetCollection.addCustomWidget(VSCodeEditorPlugin())
if __name__ == "__main__": # pragma: no cover

View File

@@ -0,0 +1 @@
{'files': ['vscode.py']}

View File

@@ -5,19 +5,17 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import (
DeviceInitializationProgressBar,
)
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
DOM_XML = """
<ui language='c++'>
<widget class='DeviceInitializationProgressBar' name='device_initialization_progress_bar'>
<widget class='VSCodeEditor' name='vs_code_editor'>
</widget>
</ui>
"""
class DeviceInitializationProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
class VSCodeEditorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
@@ -25,20 +23,20 @@ class DeviceInitializationProgressBarPlugin(QDesignerCustomWidgetInterface): #
def createWidget(self, parent):
if parent is None:
return QWidget()
t = DeviceInitializationProgressBar(parent)
t = VSCodeEditor(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
return "BEC Developer"
def icon(self):
return designer_material_icon(DeviceInitializationProgressBar.ICON_NAME)
return designer_material_icon(VSCodeEditor.ICON_NAME)
def includeFile(self):
return "device_initialization_progress_bar"
return "vs_code_editor"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -50,10 +48,10 @@ class DeviceInitializationProgressBarPlugin(QDesignerCustomWidgetInterface): #
return self._form_editor is not None
def name(self):
return "DeviceInitializationProgressBar"
return "VSCodeEditor"
def toolTip(self):
return "A progress bar that displays the progress of device initialization."
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,203 @@
import os
import select
import shlex
import signal
import socket
import subprocess
from typing import Literal
from pydantic import BaseModel
from qtpy.QtCore import Signal, Slot
from bec_widgets.widgets.editors.website.website import WebsiteWidget
class VSCodeInstructionMessage(BaseModel):
command: Literal["open", "write", "close", "zenMode", "save", "new", "setCursor"]
content: str = ""
def get_free_port():
"""
Get a free port on the local machine.
Returns:
int: The free port number
"""
sock = socket.socket()
sock.bind(("", 0))
port = sock.getsockname()[1]
sock.close()
return port
class VSCodeEditor(WebsiteWidget):
"""
A widget to display the VSCode editor.
"""
file_saved = Signal(str)
token = "bec"
host = "127.0.0.1"
PLUGIN = True
USER_ACCESS = []
ICON_NAME = "developer_mode_tv"
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
self.process = None
self.port = get_free_port()
self._url = f"http://{self.host}:{self.port}?tkn={self.token}"
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id, **kwargs)
self.start_server()
self.bec_dispatcher.connect_slot(self.on_vscode_event, f"vscode-events/{self.gui_id}")
def start_server(self):
"""
Start the server.
This method starts the server for the VSCode editor in a subprocess.
"""
env = os.environ.copy()
env["BEC_Widgets_GUIID"] = self.gui_id
env["BEC_REDIS_HOST"] = self.client.connector.host
cmd = shlex.split(
f"code serve-web --port {self.port} --connection-token={self.token} --accept-server-license-terms"
)
self.process = subprocess.Popen(
cmd,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
preexec_fn=os.setsid,
env=env,
)
os.set_blocking(self.process.stdout.fileno(), False)
while self.process.poll() is None:
readylist, _, _ = select.select([self.process.stdout], [], [], 1)
if self.process.stdout in readylist:
output = self.process.stdout.read(1024)
if output and f"available at {self._url}" in output:
break
self.set_url(self._url)
self.wait_until_loaded()
@Slot(str)
def open_file(self, file_path: str):
"""
Open a file in the VSCode editor.
Args:
file_path: The file path to open
"""
msg = VSCodeInstructionMessage(command="open", content=f"file://{file_path}")
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
@Slot(dict, dict)
def on_vscode_event(self, content, _metadata):
"""
Handle the VSCode event. VSCode events are received as RawMessages.
Args:
content: The content of the event
metadata: The metadata of the event
"""
# the message also contains the content but I think is fine for now to just emit the file path
if not isinstance(content["data"], dict):
return
if "uri" not in content["data"]:
return
if not content["data"]["uri"].startswith("file://"):
return
file_path = content["data"]["uri"].split("file://")[1]
self.file_saved.emit(file_path)
@Slot()
def save_file(self):
"""
Save the file in the VSCode editor.
"""
msg = VSCodeInstructionMessage(command="save")
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
@Slot()
def new_file(self):
"""
Create a new file in the VSCode editor.
"""
msg = VSCodeInstructionMessage(command="new")
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
@Slot()
def close_file(self):
"""
Close the file in the VSCode editor.
"""
msg = VSCodeInstructionMessage(command="close")
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
@Slot(str)
def write_file(self, content: str):
"""
Write content to the file in the VSCode editor.
Args:
content: The content to write
"""
msg = VSCodeInstructionMessage(command="write", content=content)
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
@Slot()
def zen_mode(self):
"""
Toggle the Zen mode in the VSCode editor.
"""
msg = VSCodeInstructionMessage(command="zenMode")
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
@Slot(int, int)
def set_cursor(self, line: int, column: int):
"""
Set the cursor in the VSCode editor.
Args:
line: The line number
column: The column number
"""
msg = VSCodeInstructionMessage(command="setCursor", content=f"{line},{column}")
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
def cleanup_vscode(self):
"""
Cleanup the VSCode editor.
"""
if not self.process or self.process.poll() is not None:
return
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
self.process.wait()
def cleanup(self):
"""
Cleanup the widget. This method is called from the dock area when the widget is removed.
"""
self.bec_dispatcher.disconnect_slot(self.on_vscode_event, f"vscode-events/{self.gui_id}")
self.cleanup_vscode()
return super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = VSCodeEditor(gui_id="unknown")
widget.show()
app.exec_()
widget.bec_dispatcher.disconnect_all()
widget.client.shutdown()

View File

@@ -1453,7 +1453,7 @@ class Heatmap(ImageBase):
# Post Processing
################################################################################
@SafeProperty(bool, auto_emit=True)
@SafeProperty(bool)
def fft(self) -> bool:
"""
Whether FFT postprocessing is enabled.
@@ -1470,7 +1470,7 @@ class Heatmap(ImageBase):
"""
self.main_image.fft = enable
@SafeProperty(bool, auto_emit=True)
@SafeProperty(bool)
def log(self) -> bool:
"""
Whether logarithmic scaling is applied.
@@ -1504,7 +1504,7 @@ class Heatmap(ImageBase):
"""
self.main_image.num_rotation_90 = value
@SafeProperty(bool, auto_emit=True)
@SafeProperty(bool)
def transpose(self) -> bool:
"""
Whether the image is transposed.

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,6 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError
from qtpy.QtCore import QPointF, Signal, SignalInstance
from qtpy.QtWidgets import QDialog, QVBoxLayout
from bec_widgets.utils import Colors
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
@@ -132,9 +131,8 @@ class ImageLayerManager:
image.setZValue(z_position)
image.removed.connect(self._remove_destroyed_layer)
color_map = getattr(getattr(self.parent, "config", None), "color_map", None)
if color_map:
image.color_map = color_map
# FIXME: For now, we hard-code the default color map here. In the future, this should be configurable.
image.color_map = "plasma"
self.layers[name] = ImageLayer(name=name, image=image, sync=sync)
self.plot_item.addItem(image)
@@ -251,8 +249,6 @@ class ImageBase(PlotBase):
Base class for the Image widget.
"""
MAX_TICKS_COLORBAR = 10
sync_colorbar_with_autorange = Signal()
image_updated = Signal()
layer_added = Signal(str)
@@ -464,20 +460,18 @@ class ImageBase(PlotBase):
self.setProperty("autorange", False)
if style == "simple":
cmap = Colors.get_colormap(self.config.color_map)
self._color_bar = pg.ColorBarItem(colorMap=cmap)
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)
self.config.color_bar = "simple"
elif style == "full":
self._color_bar = pg.HistogramLUTItem()
self._color_bar.setImageItem(self.layer_manager["main"].image)
self.config.color_bar = "full"
self._apply_colormap_to_colorbar(self.config.color_map)
self._color_bar.gradient.loadPreset(self.config.color_map)
self._color_bar.sigLevelsChanged.connect(disable_autorange)
self.plot_widget.addItem(self._color_bar, row=0, col=1)
self.config.color_bar = style
else:
if self._color_bar:
self.plot_widget.removeItem(self._color_bar)
@@ -490,37 +484,6 @@ class ImageBase(PlotBase):
if vrange: # should be at the end to disable the autorange if defined
self.v_range = vrange
def _apply_colormap_to_colorbar(self, color_map: str) -> None:
if not self._color_bar:
return
cmap = Colors.get_colormap(color_map)
if self.config.color_bar == "simple":
self._color_bar.setColorMap(cmap)
return
if self.config.color_bar != "full":
return
gradient = getattr(self._color_bar, "gradient", None)
if gradient is None:
return
positions = np.linspace(0.0, 1.0, self.MAX_TICKS_COLORBAR)
colors = cmap.map(positions, mode="byte")
colors = np.asarray(colors)
if colors.ndim != 2:
return
if colors.shape[1] == 3: # add alpha
alpha = np.full((colors.shape[0], 1), 255, dtype=colors.dtype)
colors = np.concatenate([colors, alpha], axis=1)
ticks = [(float(p), tuple(int(x) for x in c)) for p, c in zip(positions, colors)]
state = {"mode": "rgb", "ticks": ticks}
gradient.restoreState(state)
################################################################################
# Static rois with roi manager
@@ -791,11 +754,11 @@ class ImageBase(PlotBase):
layer.image.color_map = value
if self._color_bar:
self._apply_colormap_to_colorbar(self.config.color_map)
except ValidationError as exc:
logger.warning(
f"Colormap '{value}' is not available; keeping '{self.config.color_map}'. {exc}"
)
if self.config.color_bar == "simple":
self._color_bar.setColorMap(value)
elif self.config.color_bar == "full":
self._color_bar.gradient.loadPreset(value)
except ValidationError:
return
@SafeProperty("QPointF")

View File

@@ -119,8 +119,7 @@ class ImageItem(BECConnector, pg.ImageItem):
"""Set a new color map."""
try:
self.config.color_map = value
cmap = Colors.get_colormap(self.config.color_map)
self.setColorMap(cmap)
self.setColorMap(value)
except ValidationError:
logger.error(f"Invalid colormap '{value}' provided.")

View File

@@ -1,255 +0,0 @@
from qtpy.QtWidgets import QHBoxLayout, QSizePolicy, QWidget
from bec_widgets.utils.toolbars.actions import WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
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
class DeviceSelection(QWidget):
"""Device and signal selection widget for image toolbar."""
def __init__(self, parent=None, client=None):
super().__init__(parent=parent)
self.client = client
self.supported_signals = [
"PreviewSignal",
"AsyncSignal",
"AsyncMultiSignal",
"DynamicSignal",
]
# Create device combobox with signal class filter
# This will only show devices that have signals matching the supported signal classes
self.device_combo_box = DeviceComboBox(
parent=self, client=self.client, signal_class_filter=self.supported_signals
)
self.device_combo_box.setToolTip("Select Device")
self.device_combo_box.setEditable(True)
# Set expanding size policy so it grows with available space
self.device_combo_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.device_combo_box.lineEdit().setPlaceholderText("Select Device")
# Configure SignalComboBox to filter by PreviewSignal and supported async signals
# Also filter by ndim (1D and 2D only) for Image widget
self.signal_combo_box = SignalComboBox(
parent=self,
client=self.client,
signal_class_filter=[
"PreviewSignal",
"AsyncSignal",
"AsyncMultiSignal",
"DynamicSignal",
],
ndim_filter=[1, 2], # Only show 1D and 2D signals for Image widget
store_signal_config=True,
require_device=True,
)
self.signal_combo_box.setToolTip("Select Signal")
self.signal_combo_box.setEditable(True)
# Set expanding size policy so it grows with available space
self.signal_combo_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.signal_combo_box.lineEdit().setPlaceholderText("Select Signal")
# Connect comboboxes together
self.device_combo_box.currentTextChanged.connect(self.signal_combo_box.set_device)
self.device_combo_box.device_reset.connect(self.signal_combo_box.reset_selection)
# Simple horizontal layout with stretch to fill space
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
layout.addWidget(self.device_combo_box, stretch=1)
layout.addWidget(self.signal_combo_box, stretch=1)
def set_device_and_signal(self, device_name: str | None, device_entry: str | None) -> None:
"""Set the displayed device and signal without emitting selection signals."""
device_name = device_name or ""
device_entry = device_entry or ""
self.device_combo_box.blockSignals(True)
self.signal_combo_box.blockSignals(True)
try:
if device_name:
# Set device in device_combo_box
index = self.device_combo_box.findText(device_name)
if index >= 0:
self.device_combo_box.setCurrentIndex(index)
else:
# Device not found in list, but still set it
self.device_combo_box.setCurrentText(device_name)
# Only update signal combobox device filter if it's actually changing
# This prevents redundant repopulation which can cause duplicates !!!!
current_device = getattr(self.signal_combo_box, "_device", None)
if current_device != device_name:
self.signal_combo_box.set_device(device_name)
# Sync signal combobox selection
if device_entry:
# Try to find the signal by component_name (which is what's displayed)
found = False
for i in range(self.signal_combo_box.count()):
text = self.signal_combo_box.itemText(i)
config_data = self.signal_combo_box.itemData(i)
# Check if this matches our signal
if config_data:
component_name = config_data.get("component_name", "")
if text == component_name or text == device_entry:
self.signal_combo_box.setCurrentIndex(i)
found = True
break
if not found:
# Fallback: try to match the device_entry directly
index = self.signal_combo_box.findText(device_entry)
if index >= 0:
self.signal_combo_box.setCurrentIndex(index)
else:
# No device set, clear selections
self.device_combo_box.setCurrentText("")
self.signal_combo_box.reset_selection()
finally:
# Always unblock signals
self.device_combo_box.blockSignals(False)
self.signal_combo_box.blockSignals(False)
def set_connection_status(self, status: str, message: str | None = None) -> None:
tooltip = f"Connection status: {status}"
if message:
tooltip = f"{tooltip}\n{message}"
self.device_combo_box.setToolTip(tooltip)
self.signal_combo_box.setToolTip(tooltip)
if not self.device_combo_box.is_valid_input or not self.signal_combo_box.is_valid_input:
return
if status == "error":
style = "border: 1px solid orange;"
else:
style = "border: 1px solid transparent;"
self.device_combo_box.setStyleSheet(style)
self.signal_combo_box.setStyleSheet(style)
def cleanup(self):
"""Clean up the widget resources."""
self.device_combo_box.close()
self.device_combo_box.deleteLater()
self.signal_combo_box.close()
self.signal_combo_box.deleteLater()
def device_selection_bundle(components: ToolbarComponents, client=None) -> ToolbarBundle:
"""
Creates a device selection toolbar bundle for Image widget.
Includes a resizable splitter after the device selection. All subsequent bundles'
actions will appear compactly after the splitter with no gaps.
Args:
components (ToolbarComponents): The components to be added to the bundle.
client: The BEC client instance.
Returns:
ToolbarBundle: The device selection toolbar bundle.
"""
device_selection_widget = DeviceSelection(parent=components.toolbar, client=client)
components.add_safe(
"device_selection", WidgetAction(widget=device_selection_widget, adjust_size=False)
)
bundle = ToolbarBundle("device_selection", components)
bundle.add_action("device_selection")
bundle.add_splitter(
name="device_selection_splitter",
target_widget=device_selection_widget,
min_width=210,
max_width=600,
)
return bundle
class DeviceSelectionConnection(BundleConnection):
"""
Connection helper for the device selection bundle.
"""
def __init__(self, components: ToolbarComponents, target_widget=None):
super().__init__(parent=components.toolbar)
self.bundle_name = "device_selection"
self.components = components
self.target_widget = target_widget
self._connected = False
self.register_property_sync("device_name", self._sync_from_device_name)
self.register_property_sync("device_entry", self._sync_from_device_entry)
self.register_property_sync("connection_status", self._sync_connection_status)
self.register_property_sync("connection_error", self._sync_connection_status)
def _widget(self) -> DeviceSelection:
return self.components.get_action("device_selection").widget
def connect(self):
if self._connected:
return
widget = self._widget()
widget.device_combo_box.device_selected.connect(
self.target_widget.on_device_selection_changed
)
widget.signal_combo_box.device_signal_changed.connect(
self.target_widget.on_device_selection_changed
)
self.connect_property_sync(self.target_widget)
self._connected = True
def disconnect(self):
if not self._connected:
return
widget = self._widget()
widget.device_combo_box.device_selected.disconnect(
self.target_widget.on_device_selection_changed
)
widget.signal_combo_box.device_signal_changed.disconnect(
self.target_widget.on_device_selection_changed
)
self.disconnect_property_sync(self.target_widget)
self._connected = False
widget.cleanup()
def _sync_from_device_name(self, _):
try:
widget = self._widget()
except Exception:
return
widget.set_device_and_signal(
self.target_widget.device_name, self.target_widget.device_entry
)
self.target_widget._sync_device_entry_from_toolbar()
def _sync_from_device_entry(self, _):
try:
widget = self._widget()
except Exception:
return
widget.set_device_and_signal(
self.target_widget.device_name, self.target_widget.device_entry
)
def _sync_connection_status(self, _):
try:
widget = self._widget()
except Exception:
return
widget.set_connection_status(
self.target_widget._config.connection_status,
self.target_widget._config.connection_error,
)

View File

@@ -300,14 +300,9 @@ def image_processing(components: ToolbarComponents) -> ToolbarBundle:
class ImageProcessingConnection(BundleConnection):
"""
Connection class for the image processing toolbar bundle.
Provides bidirectional synchronization between toolbar actions and widget properties:
- Toolbar clicks → Update properties
- Property changes → Update toolbar (via property_changed signal)
"""
def __init__(self, components: ToolbarComponents, target_widget=None):
super().__init__(parent=components.toolbar)
self.bundle_name = "image_processing"
self.components = components
self.target_widget = target_widget
@@ -320,6 +315,7 @@ class ImageProcessingConnection(BundleConnection):
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")
@@ -328,11 +324,6 @@ class ImageProcessingConnection(BundleConnection):
self.reset = components.get_action("image_processing_reset")
self._connected = False
# Register property sync methods for bidirectional sync
self.register_checked_action_sync("fft", self.fft)
self.register_checked_action_sync("log", self.log)
self.register_checked_action_sync("transpose", self.transpose)
@SafeSlot()
def toggle_fft(self):
checked = self.fft.action.isChecked()
@@ -376,11 +367,8 @@ class ImageProcessingConnection(BundleConnection):
def connect(self):
"""
Connect the actions to the target widget's methods.
Enables bidirectional sync: toolbar ↔ properties.
"""
self._connected = True
# Toolbar → Property connections
self.fft.action.triggered.connect(self.toggle_fft)
self.log.action.triggered.connect(self.toggle_log)
self.transpose.action.triggered.connect(self.toggle_transpose)
@@ -388,25 +376,15 @@ class ImageProcessingConnection(BundleConnection):
self.left.action.triggered.connect(self.rotate_left)
self.reset.action.triggered.connect(self.reset_settings)
# Property → Toolbar connections
self.connect_property_sync(self.target_widget)
def disconnect(self):
"""
Disconnect the actions from the target widget's methods.
"""
if not self._connected:
return
# Disconnect toolbar → property
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)
# Disconnect property → toolbar
self.disconnect_property_sync(self.target_widget)
self._connected = False

View File

@@ -1,4 +1,4 @@
from qtpy.QtWidgets import QHBoxLayout, QSizePolicy, QWidget
from qtpy.QtWidgets import QHBoxLayout, QWidget
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
@@ -8,8 +8,6 @@ from bec_widgets.widgets.control.device_input.device_combobox.device_combobox im
class MotorSelection(QWidget):
"""Motor selection widget for MotorMap toolbar."""
def __init__(self, parent=None):
super().__init__(parent=parent)
@@ -19,22 +17,18 @@ class MotorSelection(QWidget):
self.motor_x.setToolTip("Select Motor X")
self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
self.motor_x.setEditable(True)
self.motor_x.setMinimumWidth(60)
self.motor_y = DeviceComboBox(parent=self, 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.motor_y.setEditable(True)
self.motor_y.setMinimumWidth(60)
# Simple horizontal layout with stretch to fill space
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
layout.addWidget(self.motor_x, stretch=1) # Equal stretch
layout.addWidget(self.motor_y, stretch=1) # Equal stretch
layout.setSpacing(0)
layout.addWidget(self.motor_x)
layout.addWidget(self.motor_y)
def set_motors(self, motor_x: str | None, motor_y: str | None) -> None:
"""Set the displayed motors without emitting selection signals."""
@@ -71,9 +65,6 @@ def motor_selection_bundle(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a workspace toolbar bundle for MotorMap.
Includes a resizable splitter after the motor selection. All subsequent bundles'
actions will appear compactly after the splitter with no gaps.
Args:
components (ToolbarComponents): The components to be added to the bundle.
@@ -88,14 +79,6 @@ def motor_selection_bundle(components: ToolbarComponents) -> ToolbarBundle:
bundle = ToolbarBundle("motor_selection", components)
bundle.add_action("motor_selection")
bundle.add_splitter(
name="motor_selection_splitter",
target_widget=motor_selection_widget,
min_width=170,
max_width=400,
)
return bundle

View File

@@ -446,7 +446,7 @@ class PlotBase(BECWidget, QWidget):
else:
logger.warning(f"Property {key} not found.")
@SafeProperty(str, auto_emit=True, doc="The title of the axes.")
@SafeProperty(str, doc="The title of the axes.")
def title(self) -> str:
"""
Set title of the plot.
@@ -462,8 +462,9 @@ class PlotBase(BECWidget, QWidget):
value(str): The title to set.
"""
self.plot_item.setTitle(value)
self.property_changed.emit("title", value)
@SafeProperty(str, auto_emit=True, doc="The text of the x label")
@SafeProperty(str, doc="The text of the x label")
def x_label(self) -> str:
"""
The set label for the x-axis.
@@ -480,6 +481,7 @@ class PlotBase(BECWidget, QWidget):
"""
self._user_x_label = value
self._apply_x_label()
self.property_changed.emit("x_label", self._user_x_label)
@property
def x_label_suffix(self) -> str:
@@ -533,7 +535,7 @@ class PlotBase(BECWidget, QWidget):
if self.plot_item.getAxis("bottom").isVisible():
self.plot_item.setLabel("bottom", text=final_label)
@SafeProperty(str, auto_emit=True, doc="The text of the y label")
@SafeProperty(str, doc="The text of the y label")
def y_label(self) -> str:
"""
The set label for the y-axis.
@@ -549,6 +551,7 @@ class PlotBase(BECWidget, QWidget):
"""
self._user_y_label = value
self._apply_y_label()
self.property_changed.emit("y_label", value)
@property
def y_label_suffix(self) -> str:
@@ -769,7 +772,7 @@ class PlotBase(BECWidget, QWidget):
"""
self.y_limits = (self.y_lim[0], value)
@SafeProperty(bool, auto_emit=True, doc="Show grid on the x-axis.")
@SafeProperty(bool, doc="Show grid on the x-axis.")
def x_grid(self) -> bool:
"""
Show grid on the x-axis.
@@ -785,8 +788,9 @@ class PlotBase(BECWidget, QWidget):
value(bool): The value to set.
"""
self.plot_item.showGrid(x=value)
self.property_changed.emit("x_grid", value)
@SafeProperty(bool, auto_emit=True, doc="Show grid on the y-axis.")
@SafeProperty(bool, doc="Show grid on the y-axis.")
def y_grid(self) -> bool:
"""
Show grid on the y-axis.
@@ -802,8 +806,9 @@ class PlotBase(BECWidget, QWidget):
value(bool): The value to set.
"""
self.plot_item.showGrid(y=value)
self.property_changed.emit("y_grid", value)
@SafeProperty(bool, auto_emit=True, doc="Set X-axis to log scale if True, linear if False.")
@SafeProperty(bool, doc="Set X-axis to log scale if True, linear if False.")
def x_log(self) -> bool:
"""
Set X-axis to log scale if True, linear if False.
@@ -819,8 +824,9 @@ class PlotBase(BECWidget, QWidget):
value(bool): The value to set.
"""
self.plot_item.setLogMode(x=value)
self.property_changed.emit("x_log", value)
@SafeProperty(bool, auto_emit=True, doc="Set Y-axis to log scale if True, linear if False.")
@SafeProperty(bool, doc="Set Y-axis to log scale if True, linear if False.")
def y_log(self) -> bool:
"""
Set Y-axis to log scale if True, linear if False.
@@ -836,8 +842,9 @@ class PlotBase(BECWidget, QWidget):
value(bool): The value to set.
"""
self.plot_item.setLogMode(y=value)
self.property_changed.emit("y_log", value)
@SafeProperty(bool, auto_emit=True, doc="Show the outer axes of the plot widget.")
@SafeProperty(bool, doc="Show the outer axes of the plot widget.")
def outer_axes(self) -> bool:
"""
Show the outer axes of the plot widget.
@@ -856,8 +863,9 @@ class PlotBase(BECWidget, QWidget):
self.plot_item.showAxis("right", value)
self._outer_axes_visible = value
self.property_changed.emit("outer_axes", value)
@SafeProperty(bool, auto_emit=True, doc="Show inner axes of the plot widget.")
@SafeProperty(bool, doc="Show inner axes of the plot widget.")
def inner_axes(self) -> bool:
"""
Show inner axes of the plot widget.
@@ -878,6 +886,7 @@ class PlotBase(BECWidget, QWidget):
self._inner_axes_visible = value
self._apply_x_label()
self._apply_y_label()
self.property_changed.emit("inner_axes", value)
@SafeProperty(bool, doc="Invert X axis.")
def invert_x(self) -> bool:
@@ -1101,9 +1110,7 @@ class PlotBase(BECWidget, QWidget):
self.unhook_crosshair()
@SafeProperty(
int,
auto_emit=True,
doc="Minimum decimal places for crosshair when dynamic precision is enabled.",
int, doc="Minimum decimal places for crosshair when dynamic precision is enabled."
)
def minimal_crosshair_precision(self) -> int:
"""
@@ -1123,6 +1130,7 @@ class PlotBase(BECWidget, QWidget):
self._minimal_crosshair_precision = value_int
if self.crosshair is not None:
self.crosshair.min_precision = value_int
self.property_changed.emit("minimal_crosshair_precision", value_int)
@SafeSlot()
def reset(self) -> None:

View File

@@ -82,7 +82,7 @@ class BECProgressBar(BECWidget, QWidget):
# Color settings
self._background_color = QColor(30, 30, 30)
self._progress_color = accent_colors.highlight
self._progress_color = accent_colors.highlight # QColor(210, 55, 130)
self._completed_color = accent_colors.success
self._border_color = QColor(50, 50, 50)
@@ -91,6 +91,7 @@ class BECProgressBar(BECWidget, QWidget):
# Progressbar state handling
self._state = ProgressState.NORMAL
# self._state_colors = dict(PROGRESS_STATE_COLORS)
self._state_colors = {
ProgressState.NORMAL: accent_colors.default,
@@ -108,8 +109,8 @@ class BECProgressBar(BECWidget, QWidget):
# label on top of the progress bar
self.center_label = QLabel(self)
self.center_label.setAlignment(Qt.AlignHCenter)
self.center_label.setStyleSheet("color: white;")
self.center_label.setMinimumSize(0, 0)
self.center_label.setStyleSheet("background: transparent; color: white;")
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 0, 10, 0)

View File

@@ -1,150 +0,0 @@
"""Module for a ProgressBar for device initialization progress."""
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import DeviceInitializationProgressMessage
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QApplication, QGroupBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
class DeviceInitializationProgressBar(BECWidget, QWidget):
"""A progress bar that displays the progress of device initialization."""
# Signal emitted for failed device initializations
failed_devices_changed = Signal(list)
def __init__(self, parent=None, client=None, **kwargs):
super().__init__(parent=parent, client=client, **kwargs)
self._failed_devices: list[str] = []
# Main Layout with Group Box
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(4, 4, 4, 4)
main_layout.setSpacing(0)
self.group_box = QGroupBox(self)
self.group_box.setTitle("Config Update Progress")
main_layout.addWidget(self.group_box)
lay = QVBoxLayout(self.group_box)
lay.setContentsMargins(25, 25, 25, 25)
lay.setSpacing(5)
# Progress Bar and Label in Layout
self.progress_bar = BECProgressBar(parent=parent, client=client, **kwargs)
self.progress_bar.label_template = "$value / $maximum - $percentage %"
self.progress_label = QLabel("Initializing devices...", self)
content_layout = QVBoxLayout()
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.setSpacing(0)
content_layout.addWidget(self.progress_bar)
# Layout for label, to place label properly below progress bar
# Adjust 10px left margin for aesthetic alignment
hor_layout = QHBoxLayout()
hor_layout.setContentsMargins(12, 0, 0, 0)
hor_layout.addWidget(self.progress_label)
content_layout.addLayout(hor_layout)
# Add content layout to main layout
lay.addLayout(content_layout)
self.bec_dispatcher.connect_slot(
slot=self._update_device_initialization_progress,
topics=MessageEndpoints.device_initialization_progress(),
)
self._reset_progress_bar()
@SafeProperty(list)
def failed_devices(self) -> list[str]:
"""Get the list of devices that failed to initialize.
Returns:
list[str]: A list of device identifiers that failed during initialization.
"""
return self._failed_devices
@failed_devices.setter
def failed_devices(self, value: list[str]) -> None:
self._failed_devices = value
self.failed_devices_changed.emit(self.failed_devices)
@SafeSlot()
def reset_failed_devices(self) -> None:
"""Reset the list of failed devices."""
self._failed_devices.clear()
self.failed_devices_changed.emit(self.failed_devices)
@SafeSlot(str)
def add_failed_device(self, device: str) -> None:
"""Add a device to the list of failed devices.
Args:
device (str): The identifier of the device that failed to initialize.
"""
self._failed_devices.append(device)
self.failed_devices_changed.emit(self.failed_devices)
@SafeSlot(dict, dict)
def _update_device_initialization_progress(self, msg: dict, metadata: dict) -> None:
"""Update the progress bar based on device initialization progress messages.
Args:
msg (dict): The device initialization progress message.
metadata (dict): Additional metadata about the message.
"""
msg: DeviceInitializationProgressMessage = (
DeviceInitializationProgressMessage.model_validate(msg)
)
# Reset progress bar if index has gone backwards, this indicates a new initialization sequence
old_value = self.progress_bar._user_value
if msg.index < old_value:
self._reset_progress_bar()
# Update progress based on message content
if msg.finished is False:
self.progress_label.setText(f"{msg.device} initialization in progress...")
elif msg.finished is True and msg.success is False:
self.add_failed_device(msg.device)
self.progress_label.setText(f"{msg.device} initialization failed!")
else:
self.progress_label.setText(f"{msg.device} initialization succeeded!")
self.progress_bar.set_maximum(msg.total)
self.progress_bar.set_value(msg.index)
self._update_tool_tip()
def _reset_progress_bar(self) -> None:
"""Reset the progress bar to its initial state."""
self.progress_bar.set_value(0)
self.progress_bar.set_maximum(100)
self.reset_failed_devices()
self._update_tool_tip()
def _update_tool_tip(self) -> None:
"""Update the tooltip to show failed devices if any."""
if self._failed_devices:
failed_devices_str = ", ".join(sorted(self._failed_devices))
self.setToolTip(f"Failed devices: {failed_devices_str}")
else:
self.setToolTip("No device initialization failures.")
if __name__ == "__main__": # pragma: no cover
import sys
from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("dark")
progressBar = DeviceInitializationProgressBar()
def my_cb(devices: list):
print("Failed devices:", devices)
progressBar.failed_devices_changed.connect(my_cb)
progressBar.show()
sys.exit(app.exec())

View File

@@ -1 +0,0 @@
{'files': ['device_initialization_progress_bar.py']}

View File

@@ -0,0 +1 @@
from .ring_progress_bar import RingProgressBar

View File

@@ -1,88 +1,130 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Callable, Literal
from typing import Literal, Optional
from bec_lib.endpoints import EndpointInfo, MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field
from qtpy import QtCore, QtGui
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QWidget
from pydantic import BaseModel, Field, field_validator
from pydantic_core import PydanticCustomError
from qtpy import QtGui
from qtpy.QtCore import QObject
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils import BECConnector, ConnectionConfig
logger = bec_logger.logger
if TYPE_CHECKING:
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import (
RingProgressContainerWidget,
)
class ProgressbarConnections(BaseModel):
slot: Literal["on_scan_progress", "on_device_readback", None] = None
endpoint: EndpointInfo | str | None = None
model_config: dict = {"validate_assignment": True}
@field_validator("endpoint")
@classmethod
def validate_endpoint(cls, v, values):
slot = values.data["slot"]
v = v.endpoint if isinstance(v, EndpointInfo) else v
if slot == "on_scan_progress":
if v != MessageEndpoints.scan_progress().endpoint:
raise PydanticCustomError(
"unsupported endpoint",
"For slot 'on_scan_progress', endpoint must be MessageEndpoint.scan_progress or 'scans/scan_progress'.",
{"wrong_value": v},
)
elif slot == "on_device_readback":
if not v.startswith(MessageEndpoints.device_readback("").endpoint):
raise PydanticCustomError(
"unsupported endpoint",
"For slot 'on_device_readback', endpoint must be MessageEndpoint.device_readback(device) or 'internal/devices/readback/{device}'.",
{"wrong_value": v},
)
return v
class ProgressbarConfig(ConnectionConfig):
value: int | float = Field(0, description="Value for the progress bars.")
direction: int = Field(
value: int | float | None = Field(0, description="Value for the progress bars.")
direction: int | None = Field(
-1, description="Direction of the progress bars. -1 for clockwise, 1 for counter-clockwise."
)
color: str | tuple = Field(
color: str | tuple | None = Field(
(0, 159, 227, 255),
description="Color for the progress bars. Can be tuple (R, G, B, A) or string HEX Code.",
)
background_color: str | tuple = Field(
background_color: str | tuple | None = Field(
(200, 200, 200, 50),
description="Background color for the progress bars. Can be tuple (R, G, B, A) or string HEX Code.",
)
link_colors: bool = Field(
True,
description="Whether to link the background color to the main color. If True, changing the main color will also change the background color.",
)
line_width: int = Field(20, description="Line widths for the progress bars.")
start_position: int = Field(
index: int | None = Field(0, description="Index of the progress bar. 0 is outer ring.")
line_width: int | None = Field(10, description="Line widths for the progress bars.")
start_position: int | None = Field(
90,
description="Start position for the progress bars in degrees. Default is 90 degrees - corespons to "
"the top of the ring.",
)
min_value: int | float = Field(0, description="Minimum value for the progress bars.")
max_value: int | float = Field(100, description="Maximum value for the progress bars.")
precision: int = Field(3, description="Precision for the progress bars.")
mode: Literal["manual", "scan", "device"] = Field(
"manual", description="Update mode for the progress bars."
min_value: int | float | None = Field(0, description="Minimum value for the progress bars.")
max_value: int | float | None = Field(100, description="Maximum value for the progress bars.")
precision: int | None = Field(3, description="Precision for the progress bars.")
update_behaviour: Literal["manual", "auto"] | None = Field(
"auto", description="Update behaviour for the progress bars."
)
device: str | None = Field(
None,
description="Device name for the device readback mode, only used when mode is 'device'.",
)
signal: str | None = Field(
None,
description="Signal name for the device readback mode, only used when mode is 'device'.",
connections: ProgressbarConnections | None = Field(
default_factory=ProgressbarConnections, description="Connections for the progress bars."
)
class Ring(BECConnector, QWidget):
class RingConfig(ProgressbarConfig):
index: int | None = Field(0, description="Index of the progress bar. 0 is outer ring.")
start_position: int | None = Field(
90,
description="Start position for the progress bars in degrees. Default is 90 degrees - corespons to "
"the top of the ring.",
)
class Ring(BECConnector, QObject):
USER_ACCESS = [
"_get_all_rpc",
"_rpc_id",
"_config_dict",
"set_value",
"set_color",
"set_background",
"set_colors_linked",
"set_line_width",
"set_min_max_values",
"set_start_angle",
"set_update",
"set_precision",
"reset_connection",
]
RPC = True
def __init__(self, parent: RingProgressContainerWidget | None = None, client=None, **kwargs):
self.progress_container = parent
self.config: ProgressbarConfig = ProgressbarConfig(widget_class=self.__class__.__name__) # type: ignore
super().__init__(parent=parent, client=client, config=self.config, **kwargs)
self._color: QColor = self.convert_color(self.config.color)
self._background_color: QColor = self.convert_color(self.config.background_color)
self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None
def __init__(
self,
parent=None,
config: RingConfig | dict | None = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
):
if config is None:
config = RingConfig(widget_class=self.__class__.__name__)
self.config = config
else:
if isinstance(config, dict):
config = RingConfig(**config)
self.config = config
super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs)
self.parent_progress_widget = parent
self.color = None
self.background_color = None
self.start_position = None
self.config = config
self.RID = None
self._gap = 5
self._init_config_params()
def _init_config_params(self):
self.color = self.convert_color(self.config.color)
self.background_color = self.convert_color(self.config.background_color)
self.set_start_angle(self.config.start_position)
if self.config.connections:
self.set_connections(self.config.connections.slot, self.config.connections.endpoint)
def set_value(self, value: int | float):
"""
@@ -91,7 +133,11 @@ class Ring(BECConnector, QWidget):
Args:
value(int | float): Value for the ring widget
"""
self.value = value
self.config.value = round(
float(max(self.config.min_value, min(self.config.max_value, value))),
self.config.precision,
)
self.parent_progress_widget.update()
def set_color(self, color: str | tuple):
"""
@@ -100,53 +146,20 @@ class Ring(BECConnector, QWidget):
Args:
color(str | tuple): Color for the ring widget. Can be HEX code or tuple (R, G, B, A).
"""
self._color = self.convert_color(color)
self.config.color = self._color.name()
self.config.color = color
self.color = self.convert_color(color)
self.parent_progress_widget.update()
# Automatically set background color
if self.config.link_colors:
self._auto_set_background_color()
self.update()
def set_background(self, color: str | tuple | QColor):
def set_background(self, color: str | tuple):
"""
Set the background color for the ring widget. The background color is only used when colors are not linked.
Set the background color for the ring widget
Args:
color(str | tuple): Background color for the ring widget. Can be HEX code or tuple (R, G, B, A).
"""
# Only allow manual background color changes when colors are not linked
if self.config.link_colors:
return
self._background_color = self.convert_color(color)
self.config.background_color = self._background_color.name()
self.update()
def _auto_set_background_color(self):
"""
Automatically set the background color based on the main color and the current theme.
"""
palette = self.palette()
bg = palette.color(QtGui.QPalette.ColorRole.Window)
bg_color = Colors.subtle_background_color(self._color, bg)
self.config.background_color = bg_color.name()
self._background_color = bg_color
self.update()
def set_colors_linked(self, linked: bool):
"""
Set whether the colors are linked for the ring widget.
If colors are linked, changing the main color will also change the background color.
Args:
linked(bool): Whether to link the colors for the ring widget
"""
self.config.link_colors = linked
if linked:
self._auto_set_background_color()
self.update()
self.config.background_color = color
self.color = self.convert_color(color)
self.parent_progress_widget.update()
def set_line_width(self, width: int):
"""
@@ -156,7 +169,7 @@ class Ring(BECConnector, QWidget):
width(int): Line width for the ring widget
"""
self.config.line_width = width
self.update()
self.parent_progress_widget.update()
def set_min_max_values(self, min_value: int | float, max_value: int | float):
"""
@@ -168,21 +181,35 @@ class Ring(BECConnector, QWidget):
"""
self.config.min_value = min_value
self.config.max_value = max_value
self.update()
self.parent_progress_widget.update()
def set_start_angle(self, start_angle: int):
"""
Set the start angle for the ring widget.
Set the start angle for the ring widget
Args:
start_angle(int): Start angle for the ring widget in degrees
"""
self.config.start_position = start_angle
self.update()
self.start_position = start_angle * 16
self.parent_progress_widget.update()
def set_update(
self, mode: Literal["manual", "scan", "device"], device: str = "", signal: str = ""
):
@staticmethod
def convert_color(color):
"""
Convert the color to QColor
Args:
color(str | tuple): Color for the ring widget. Can be HEX code or tuple (R, G, B, A).
"""
converted_color = None
if isinstance(color, str):
converted_color = QtGui.QColor(color)
elif isinstance(color, tuple):
converted_color = QtGui.QColor(*color)
return converted_color
def set_update(self, mode: Literal["manual", "scan", "device"], device: str = None):
"""
Set the update mode for the ring widget.
Modes:
@@ -193,167 +220,47 @@ class Ring(BECConnector, QWidget):
Args:
mode(str): Update mode for the ring widget. Can be "manual", "scan" or "device"
device(str): Device name for the device readback mode, only used when mode is "device"
signal(str): Signal name for the device readback mode, only used when mode is "device"
"""
match mode:
case "manual":
if self.config.mode == "manual":
return
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self.config.mode = "manual"
self.registered_slot = None
case "scan":
if self.config.mode == "scan":
return
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self.config.mode = "scan"
self.bec_dispatcher.connect_slot(
self.on_scan_progress, MessageEndpoints.scan_progress()
if mode == "manual":
if self.config.connections.slot is not None:
self.bec_dispatcher.disconnect_slot(
getattr(self, self.config.connections.slot), self.config.connections.endpoint
)
self.registered_slot = (self.on_scan_progress, MessageEndpoints.scan_progress())
case "device":
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self.config.mode = "device"
if device == "":
self.registered_slot = None
return
self.config.device = device
# self.config.signal = self._get_signal_from_device(device, signal)
signal = self._update_device_connection(device, signal)
self.config.signal = signal
self.config.connections.slot = None
self.config.connections.endpoint = None
elif mode == "scan":
self.set_connections("on_scan_progress", MessageEndpoints.scan_progress())
elif mode == "device":
self.set_connections("on_device_readback", MessageEndpoints.device_readback(device))
case _:
raise ValueError(f"Unsupported mode: {mode}")
self.parent_progress_widget.enable_auto_updates(False)
def set_precision(self, precision: int):
def set_connections(self, slot: str, endpoint: str | EndpointInfo):
"""
Set the precision for the ring widget.
Set the connections for the ring widget
Args:
precision(int): Precision for the ring widget
slot(str): Slot for the ring widget update. Can be "on_scan_progress" or "on_device_readback".
endpoint(str | EndpointInfo): Endpoint for the ring widget update. Endpoint has to match the slot type.
"""
self.config.precision = precision
self.update()
if self.config.connections.endpoint == endpoint and self.config.connections.slot == slot:
return
if self.config.connections.slot is not None:
self.bec_dispatcher.disconnect_slot(
getattr(self, self.config.connections.slot), self.config.connections.endpoint
)
self.config.connections = ProgressbarConnections(slot=slot, endpoint=endpoint)
self.bec_dispatcher.connect_slot(getattr(self, slot), endpoint)
def set_direction(self, direction: int):
def reset_connection(self):
"""
Set the direction for the ring widget.
Args:
direction(int): Direction for the ring widget. -1 for clockwise, 1 for counter-clockwise.
Reset the connections for the ring widget. Disconnect the current slot and endpoint.
"""
self.config.direction = direction
self.update()
self.bec_dispatcher.disconnect_slot(
self.config.connections.slot, self.config.connections.endpoint
)
self.config.connections = ProgressbarConnections()
def _get_signals_for_device(self, device: str) -> dict[str, list[str]]:
"""
Get the signals for the device.
Args:
device(str): Device name for the device
Returns:
dict[str, list[str]]: Dictionary with the signals for the device
"""
dm = self.bec_dispatcher.client.device_manager
if not dm:
raise ValueError("Device manager is not available in the BEC client.")
dev_obj = dm.devices.get(device)
if dev_obj is None:
raise ValueError(f"Device '{device}' not found in device manager.")
progress_signals = [
obj["component_name"]
for obj in dev_obj._info["signals"].values()
if obj["signal_class"] == "ProgressSignal"
]
hinted_signals = [
obj["obj_name"]
for obj in dev_obj._info["signals"].values()
if obj["kind_str"] == "hinted"
and obj["signal_class"]
not in ["ProgressSignal", "AyncSignal", "AsyncMultiSignal", "DynamicSignal"]
]
normal_signals = [
obj["component_name"]
for obj in dev_obj._info["signals"].values()
if obj["kind_str"] == "normal"
]
return {
"progress_signals": progress_signals,
"hinted_signals": hinted_signals,
"normal_signals": normal_signals,
}
def _update_device_connection(self, device: str, signal: str | None) -> str:
"""
Update the device connection for the ring widget.
In general, we support two modes here:
- If signal is provided, we use that directly.
- If signal is not provided, we try to get the signal from the device manager.
We first check for progress signals, then for hinted signals, and finally for normal signals.
Depending on what type of signal we get (progress or hinted/normal), we subscribe to different endpoints.
Args:
device(str): Device name for the device mode
signal(str): Signal name for the device mode
Returns:
str: The selected signal name for the device mode
"""
logger.info(f"Updating device connection for device '{device}' and signal '{signal}'")
dm = self.bec_dispatcher.client.device_manager
if not dm:
raise ValueError("Device manager is not available in the BEC client.")
dev_obj = dm.devices.get(device)
if dev_obj is None:
return ""
signals = self._get_signals_for_device(device)
progress_signals = signals["progress_signals"]
hinted_signals = signals["hinted_signals"]
normal_signals = signals["normal_signals"]
if not signal:
# If signal is not provided, we try to get it from the device manager
if len(progress_signals) > 0:
signal = progress_signals[0]
logger.info(
f"Using progress signal '{signal}' for device '{device}' in ring progress bar."
)
elif len(hinted_signals) > 0:
signal = hinted_signals[0]
logger.info(
f"Using hinted signal '{signal}' for device '{device}' in ring progress bar."
)
elif len(normal_signals) > 0:
signal = normal_signals[0]
logger.info(
f"Using normal signal '{signal}' for device '{device}' in ring progress bar."
)
else:
logger.warning(f"No signals found for device '{device}' in ring progress bar.")
return ""
if signal in progress_signals:
endpoint = MessageEndpoints.device_progress(device)
self.bec_dispatcher.connect_slot(self.on_device_progress, endpoint)
self.registered_slot = (self.on_device_progress, endpoint)
return signal
if signal in hinted_signals or signal in normal_signals:
endpoint = MessageEndpoints.device_readback(device)
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoint)
self.registered_slot = (self.on_device_readback, endpoint)
return signal
@SafeSlot(dict, dict)
def on_scan_progress(self, msg, meta):
"""
Update the ring widget with the scan progress.
@@ -366,9 +273,8 @@ class Ring(BECConnector, QWidget):
if current_RID != self.RID:
self.set_min_max_values(0, msg.get("max_value", 100))
self.set_value(msg.get("value", 0))
self.update()
self.parent_progress_widget.update()
@SafeSlot(dict, dict)
def on_device_readback(self, msg, meta):
"""
Update the ring widget with the device readback.
@@ -377,242 +283,11 @@ class Ring(BECConnector, QWidget):
msg(dict): Message with the device readback
meta(dict): Metadata for the message
"""
device = self.config.device
if device is None:
return
signal = self.config.signal or device
value = msg.get("signals", {}).get(signal, {}).get("value", None)
if value is None:
return
if isinstance(self.config.connections.endpoint, EndpointInfo):
endpoint = self.config.connections.endpoint.endpoint
else:
endpoint = self.config.connections.endpoint
device = endpoint.split("/")[-1]
value = msg.get("signals").get(device).get("value")
self.set_value(value)
self.update()
@SafeSlot(dict, dict)
def on_device_progress(self, msg, meta):
"""
Update the ring widget with the device progress.
Args:
msg(dict): Message with the device progress
meta(dict): Metadata for the message
"""
device = self.config.device
if device is None:
return
max_val = msg.get("max_value", 100)
self.set_min_max_values(0, max_val)
value = msg.get("value", 0)
if msg.get("done"):
value = max_val
self.set_value(value)
self.update()
def paintEvent(self, event):
if not self.progress_container:
return
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
size = min(self.width(), self.height())
# Center the ring
x_offset = (self.width() - size) // 2
y_offset = (self.height() - size) // 2
max_ring_size = self.progress_container.get_max_ring_size()
rect = QtCore.QRect(x_offset, y_offset, size, size)
rect.adjust(max_ring_size, max_ring_size, -max_ring_size, -max_ring_size)
# Background arc
painter.setPen(
QtGui.QPen(self._background_color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
)
gap: int = self.gap # type: ignore
# Important: Qt uses a 16th of a degree for angles. start_position is therefore multiplied by 16.
start_position: float = self.config.start_position * 16 # type: ignore
adjusted_rect = QtCore.QRect(
rect.left() + gap, rect.top() + gap, rect.width() - 2 * gap, rect.height() - 2 * gap
)
painter.drawArc(adjusted_rect, start_position, 360 * 16)
# Foreground arc
pen = QtGui.QPen(self.color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
proportion = (self.config.value - self.config.min_value) / (
(self.config.max_value - self.config.min_value) + 1e-3
)
angle = int(proportion * 360 * 16 * self.config.direction)
painter.drawArc(adjusted_rect, start_position, angle)
painter.end()
def convert_color(self, color: str | tuple | QColor) -> QColor:
"""
Convert the color to QColor
Args:
color(str | tuple | QColor): Color for the ring widget. Can be HEX code or tuple (R, G, B, A) or QColor.
"""
if isinstance(color, QColor):
return color
if isinstance(color, str):
return QtGui.QColor(color)
if isinstance(color, (tuple, list)):
return QtGui.QColor(*color)
raise ValueError(f"Unsupported color format: {color}")
def cleanup(self):
"""
Cleanup the ring widget.
Disconnect any registered slots.
"""
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self.registered_slot = None
###############################################
####### QProperties ###########################
###############################################
@SafeProperty(int)
def gap(self) -> int:
return self._gap
@gap.setter
def gap(self, value: int):
self._gap = value
self.update()
@SafeProperty(bool)
def link_colors(self) -> bool:
return self.config.link_colors
@link_colors.setter
def link_colors(self, value: bool):
logger.info(f"Setting link_colors to {value}")
self.set_colors_linked(value)
@SafeProperty(QColor)
def color(self) -> QColor:
return self._color
@color.setter
def color(self, value: QColor):
self.set_color(value)
@SafeProperty(QColor)
def background_color(self) -> QColor:
return self._background_color
@background_color.setter
def background_color(self, value: QColor):
self.set_background(value)
@SafeProperty(float)
def value(self) -> float:
return self.config.value
@value.setter
def value(self, value: float):
self.config.value = round(
float(max(self.config.min_value, min(self.config.max_value, value))),
self.config.precision,
)
self.update()
@SafeProperty(float)
def min_value(self) -> float:
return self.config.min_value
@min_value.setter
def min_value(self, value: float):
self.config.min_value = value
self.update()
@SafeProperty(float)
def max_value(self) -> float:
return self.config.max_value
@max_value.setter
def max_value(self, value: float):
self.config.max_value = value
self.update()
@SafeProperty(str)
def mode(self) -> str:
return self.config.mode
@mode.setter
def mode(self, value: str):
self.set_update(value)
@SafeProperty(str)
def device(self) -> str:
return self.config.device or ""
@device.setter
def device(self, value: str):
self.config.device = value
@SafeProperty(str)
def signal(self) -> str:
return self.config.signal or ""
@signal.setter
def signal(self, value: str):
self.config.signal = value
@SafeProperty(int)
def line_width(self) -> int:
return self.config.line_width
@line_width.setter
def line_width(self, value: int):
self.config.line_width = value
self.update()
@SafeProperty(int)
def start_position(self) -> int:
return self.config.start_position
@start_position.setter
def start_position(self, value: int):
self.config.start_position = value
self.update()
@SafeProperty(int)
def precision(self) -> int:
return self.config.precision
@precision.setter
def precision(self, value: int):
self.config.precision = value
self.update()
@SafeProperty(int)
def direction(self) -> int:
return self.config.direction
@direction.setter
def direction(self, value: int):
self.config.direction = value
self.update()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import apply_theme
app = QApplication(sys.argv)
apply_theme("dark")
ring = Ring()
ring.export_settings()
ring.show()
sys.exit(app.exec())
self.parent_progress_widget.update()

View File

@@ -1,154 +1,348 @@
import json
from typing import Literal
from __future__ import annotations
from typing import Literal, Optional
import pyqtgraph as pg
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
from pydantic import Field, field_validator
from pydantic_core import PydanticCustomError
from qtpy import QtCore, QtGui
from qtpy.QtCore import QSize, Slot
from qtpy.QtWidgets import QSizePolicy, QWidget
from bec_widgets.utils import Colors
from bec_widgets.utils import Colors, ConnectionConfig, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_settings_cards import RingSettings
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring, RingConfig
logger = bec_logger.logger
class RingProgressContainerWidget(QWidget):
class RingProgressBarConfig(ConnectionConfig):
color_map: Optional[str] = Field(
"plasma", description="Color scheme for the progress bars.", validate_default=True
)
min_number_of_bars: int = Field(1, description="Minimum number of progress bars to display.")
max_number_of_bars: int = Field(10, description="Maximum number of progress bars to display.")
num_bars: int = Field(1, description="Number of progress bars to display.")
gap: int | None = Field(20, description="Gap between progress bars.")
auto_updates: bool | None = Field(
True, description="Enable or disable updates based on scan queue status."
)
rings: list[RingConfig] | None = Field([], description="List of ring configurations.")
@field_validator("num_bars")
@classmethod
def validate_num_bars(cls, v, values):
min_number_of_bars = values.data.get("min_number_of_bars", None)
max_number_of_bars = values.data.get("max_number_of_bars", None)
if min_number_of_bars is not None and max_number_of_bars is not None:
logger.info(
f"Number of bars adjusted to be between defined min:{min_number_of_bars} and max:{max_number_of_bars} number of bars."
)
v = max(min_number_of_bars, min(v, max_number_of_bars))
return v
@field_validator("rings")
@classmethod
def validate_rings(cls, v, values):
if v is not None and v is not []:
num_bars = values.data.get("num_bars", None)
if len(v) != num_bars:
raise PydanticCustomError(
"different number of configs",
f"Length of rings configuration ({len(v)}) does not match the number of bars ({num_bars}).",
{"wrong_value": len(v)},
)
indices = [ring.index for ring in v]
if sorted(indices) != list(range(len(indices))):
raise PydanticCustomError(
"wrong indices",
f"Indices of ring configurations must be unique and in order from 0 to num_bars {num_bars}.",
{"wrong_value": indices},
)
return v
_validate_colormap = field_validator("color_map")(Colors.validate_color_map)
class RingProgressBar(BECWidget, QWidget):
"""
A container widget for the Ring Progress Bar widget.
It holds the rings and manages their layout and painting.
Show the progress of devices, scans or custom values in the form of ring progress bars.
"""
def __init__(self, parent: QWidget | None = None, **kwargs):
super().__init__(parent=parent, **kwargs)
self.rings: list[Ring] = []
self.gap = 20 # Gap between rings
self.color_map: str = "turbo"
self.setLayout(QHBoxLayout())
PLUGIN = True
ICON_NAME = "track_changes"
USER_ACCESS = [
"_get_all_rpc",
"_rpc_id",
"_config_dict",
"rings",
"update_config",
"add_ring",
"remove_ring",
"set_precision",
"set_min_max_values",
"set_number_of_bars",
"set_value",
"set_colors_from_map",
"set_colors_directly",
"set_line_widths",
"set_gap",
"set_diameter",
"reset_diameter",
"enable_auto_updates",
"attach",
"detach",
"screenshot",
]
def __init__(
self,
parent=None,
config: RingProgressBarConfig | dict | None = None,
client=None,
gui_id: str | None = None,
num_bars: int | None = None,
**kwargs,
):
if config is None:
config = RingProgressBarConfig(widget_class=self.__class__.__name__)
self.config = config
else:
if isinstance(config, dict):
config = RingProgressBarConfig(**config, widget_class=self.__class__.__name__)
self.config = config
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
self.entry_validator = EntryValidator(self.dev)
self.RID = None
# For updating bar behaviour
self._auto_updates = True
self._rings = []
if num_bars is not None:
self.config.num_bars = max(
self.config.min_number_of_bars, min(num_bars, self.config.max_number_of_bars)
)
self.initialize_bars()
self.initialize_center_label()
self.enable_auto_updates(self.config.auto_updates)
@property
def num_bars(self) -> int:
return len(self.rings)
def rings(self) -> list[Ring]:
"""Returns a list of all rings in the progress bar."""
return self._rings
@rings.setter
def rings(self, value: list[Ring]):
self._rings = value
def update_config(self, config: RingProgressBarConfig | dict):
"""
Update the configuration of the widget.
Args:
config(SpiralProgressBarConfig|dict): Configuration to update.
"""
if isinstance(config, dict):
config = RingProgressBarConfig(**config, widget_class=self.__class__.__name__)
self.config = config
self.clear_all()
def initialize_bars(self):
"""
Initialize the progress bars.
"""
for _ in range(self.num_bars):
self.add_ring()
start_positions = [90 * 16] * self.config.num_bars
directions = [-1] * self.config.num_bars
if self.color_map:
self.set_colors_from_map(self.color_map)
self.config.rings = [
RingConfig(
widget_class="Ring",
index=i,
start_positions=start_positions[i],
directions=directions[i],
)
for i in range(self.config.num_bars)
]
self._rings = [Ring(parent=self, config=config) for config in self.config.rings]
def add_ring(self, config: dict | None = None) -> Ring:
if self.config.color_map:
self.set_colors_from_map(self.config.color_map)
min_size = self._calculate_minimum_size()
self.setMinimumSize(min_size)
# Set outer ring to listen to scan progress
self.rings[0].set_update(mode="scan")
self.update()
def add_ring(self, **kwargs) -> Ring:
"""
Add a new ring to the container.
Add a new progress bar.
Args:
config(dict | None): Optional configuration dictionary for the ring.
**kwargs: Keyword arguments for the new progress bar.
Returns:
Ring: The newly added ring object.
Ring: Ring object.
"""
ring = Ring(parent=self)
ring.setGeometry(self.rect())
ring.gap = self.gap * len(self.rings)
ring.set_value(0)
self.rings.append(ring)
if config:
# We have to first get the link_colors property before loading the settings
# While this is an ugly hack, we do not have control over the order of properties
# being set when loading.
ring.link_colors = config.pop("link_colors", True)
ring.load_settings(config)
if self.color_map:
self.set_colors_from_map(self.color_map)
ring.show()
ring.raise_()
self.update()
return ring
if self.config.num_bars < self.config.max_number_of_bars:
ring_index = self.config.num_bars
ring_config = RingConfig(
widget_class="Ring",
index=ring_index,
start_positions=90 * 16,
directions=-1,
**kwargs,
)
ring = Ring(parent=self, config=ring_config)
self.config.num_bars += 1
self._rings.append(ring)
self.config.rings.append(ring.config)
if self.config.color_map:
self.set_colors_from_map(self.config.color_map)
base_line_width = self._rings[ring.config.index].config.line_width
self.set_line_widths(base_line_width, ring.config.index)
self.update()
return ring
def remove_ring(self, index: int | None = None):
def remove_ring(self, index: int):
"""
Remove a ring from the container.
Remove a progress bar by index.
Args:
index(int | None): Index of the ring to remove. If None, removes the last ring.
index(int): Index of the progress bar to remove.
"""
if self.num_bars == 0:
return
if index is None:
index = self.num_bars - 1
index = self._validate_index(index)
ring = self.rings[index]
ring.cleanup()
ring.close()
ring.deleteLater()
self.rings.pop(index)
# Update gaps for remaining rings
for i, r in enumerate(self.rings):
r.gap = self.gap * i
ring = self._find_ring_by_index(index)
self._cleanup_ring(ring)
self.update()
def initialize_center_label(self):
"""
Initialize the center label.
"""
layout = self.layout()
layout.setContentsMargins(0, 0, 0, 0)
def _cleanup_ring(self, ring: Ring) -> None:
ring.reset_connection()
self._rings.remove(ring)
self.config.rings.remove(ring.config)
self.config.num_bars -= 1
self._reindex_rings()
if self.config.color_map:
self.set_colors_from_map(self.config.color_map)
# Remove ring from rpc, afterwards call close event.
ring.rpc_register.remove_rpc(ring)
ring.deleteLater()
# del ring
self.center_label = QLabel("", parent=self)
self.center_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.center_label)
def _reindex_rings(self):
"""
Reindex the progress bars.
"""
for i, ring in enumerate(self._rings):
ring.config.index = i
def _calculate_minimum_size(self):
def set_precision(self, precision: int, bar_index: int | None = None):
"""
Calculate the minimum size of the widget.
"""
if not self.rings:
return QSize(10, 10)
ring_widths = self.get_ring_line_widths()
total_width = sum(ring_widths) + self.gap * (self.num_bars - 1)
diameter = max(total_width * 2, 50)
Set the precision for the progress bars. If bar_index is not provide, the precision will be set for all progress bars.
return QSize(diameter, diameter)
Args:
precision(int): Precision for the progress bars.
bar_index(int): Index of the progress bar to set the precision for. If provided, only a single precision can be set.
"""
if bar_index is not None:
bar_index = self._bar_index_check(bar_index)
ring = self._find_ring_by_index(bar_index)
ring.config.precision = precision
else:
for ring in self._rings:
ring.config.precision = precision
self.update()
def get_ring_line_widths(self):
def set_min_max_values(
self,
min_values: int | float | list[int | float],
max_values: int | float | list[int | float],
):
"""
Get the line widths of the rings.
"""
if not self.rings:
return [10]
ring_widths = [ring.config.line_width for ring in self.rings]
return ring_widths
Set the minimum and maximum values for the progress bars.
def get_max_ring_size(self) -> int:
Args:
min_values(int|float | list[float]): Minimum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of minimum values for each progress bar.
max_values(int|float | list[float]): Maximum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of maximum values for each progress bar.
"""
Get the size of the rings.
"""
if not self.rings:
return 10
ring_widths = self.get_ring_line_widths()
return max(ring_widths)
if isinstance(min_values, (int, float)):
min_values = [min_values]
if isinstance(max_values, (int, float)):
max_values = [max_values]
min_values = self._adjust_list_to_bars(min_values)
max_values = self._adjust_list_to_bars(max_values)
for ring, min_value, max_value in zip(self._rings, min_values, max_values):
ring.set_min_max_values(min_value, max_value)
self.update()
def sizeHint(self):
min_size = self._calculate_minimum_size()
return min_size
def set_number_of_bars(self, num_bars: int):
"""
Set the number of progress bars to display.
def resizeEvent(self, event):
Args:
num_bars(int): Number of progress bars to display.
"""
Handle resize events to update ring geometries.
num_bars = max(
self.config.min_number_of_bars, min(num_bars, self.config.max_number_of_bars)
)
current_num_bars = self.config.num_bars
if num_bars > current_num_bars:
for i in range(current_num_bars, num_bars):
new_ring_config = RingConfig(
widget_class="Ring", index=i, start_positions=90 * 16, directions=-1
)
self.config.rings.append(new_ring_config)
new_ring = Ring(parent=self, config=new_ring_config)
self._rings.append(new_ring)
elif num_bars < current_num_bars:
for i in range(current_num_bars - 1, num_bars - 1, -1):
self.remove_ring(i)
self.config.num_bars = num_bars
if self.config.color_map:
self.set_colors_from_map(self.config.color_map)
base_line_width = self._rings[0].config.line_width
self.set_line_widths(base_line_width)
self.update()
def set_value(self, values: int | list, ring_index: int = None):
"""
super().resizeEvent(event)
for ring in self.rings:
ring.setGeometry(self.rect())
Set the values for the progress bars.
Args:
values(int | tuple): Value(s) for the progress bars. If multiple progress bars are displayed, provide a tuple of values for each progress bar.
ring_index(int): Index of the progress bar to set the value for. If provided, only a single value can be set.
Examples:
>>> SpiralProgressBar.set_value(50)
>>> SpiralProgressBar.set_value([30, 40, 50]) # (outer, middle, inner)
>>> SpiralProgressBar.set_value(60, bar_index=1) # Set the value for the middle progress bar.
"""
if ring_index is not None:
ring = self._find_ring_by_index(ring_index)
if isinstance(values, list):
values = values[0]
logger.warning(
f"Warning: Only a single value can be set for a single progress bar. Using the first value in the list {values}"
)
ring.set_value(values)
else:
if isinstance(values, int):
values = [values]
values = self._adjust_list_to_bars(values)
for ring, value in zip(self._rings, values):
ring.set_value(value)
self.update()
def set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "RGB"):
"""
@@ -162,14 +356,12 @@ class RingProgressContainerWidget(QWidget):
raise ValueError(
f"Colormap '{colormap}' not found in the current installation of pyqtgraph"
)
colors = Colors.golden_angle_color(colormap, self.num_bars, color_format)
colors = Colors.golden_angle_color(colormap, self.config.num_bars, color_format)
self.set_colors_directly(colors)
self.color_map = colormap
self.config.color_map = colormap
self.update()
def set_colors_directly(
self, colors: list[str | tuple] | str | tuple, bar_index: int | None = None
):
def set_colors_directly(self, colors: list[str | tuple] | str | tuple, bar_index: int = None):
"""
Set the colors for the progress bars directly.
@@ -178,16 +370,170 @@ class RingProgressContainerWidget(QWidget):
bar_index(int): Index of the progress bar to set the color for. If provided, only a single color can be set.
"""
if bar_index is not None and isinstance(colors, (str, tuple)):
bar_index = self._validate_index(bar_index)
self.rings[bar_index].set_color(colors)
bar_index = self._bar_index_check(bar_index)
ring = self._find_ring_by_index(bar_index)
ring.set_color(colors)
else:
if isinstance(colors, (str, tuple)):
colors = [colors]
colors = self._adjust_list_to_bars(colors)
for ring, color in zip(self.rings, colors):
for ring, color in zip(self._rings, colors):
ring.set_color(color)
self.update()
def set_line_widths(self, widths: int | list[int], bar_index: int = None):
"""
Set the line widths for the progress bars.
Args:
widths(int | list[int]): Line width(s) for the progress bars. If multiple progress bars are displayed, provide a list of line widths for each progress bar.
bar_index(int): Index of the progress bar to set the line width for. If provided, only a single line width can be set.
"""
if bar_index is not None:
bar_index = self._bar_index_check(bar_index)
ring = self._find_ring_by_index(bar_index)
if isinstance(widths, list):
widths = widths[0]
logger.warning(
f"Warning: Only a single line width can be set for a single progress bar. Using the first value in the list {widths}"
)
ring.set_line_width(widths)
else:
if isinstance(widths, int):
widths = [widths]
widths = self._adjust_list_to_bars(widths)
self.config.gap = max(widths) * 2
for ring, width in zip(self._rings, widths):
ring.set_line_width(width)
min_size = self._calculate_minimum_size()
self.setMinimumSize(min_size)
self.update()
def set_gap(self, gap: int):
"""
Set the gap between the progress bars.
Args:
gap(int): Gap between the progress bars.
"""
self.config.gap = gap
self.update()
def set_diameter(self, diameter: int):
"""
Set the diameter of the widget.
Args:
diameter(int): Diameter of the widget.
"""
size = QSize(diameter, diameter)
self.resize(size)
self.setFixedSize(size)
def _find_ring_by_index(self, index: int) -> Ring:
"""
Find the ring by index.
Args:
index(int): Index of the ring.
Returns:
Ring: Ring object.
"""
for ring in self._rings:
if ring.config.index == index:
return ring
raise ValueError(f"Ring with index {index} not found.")
def enable_auto_updates(self, enable: bool = True):
"""
Enable or disable updates based on scan status. Overrides manual updates.
The behaviour of the whole progress bar widget will be driven by the scan queue status.
Args:
enable(bool): True or False.
Returns:
bool: True if scan segment updates are enabled.
"""
self._auto_updates = enable
if enable is True:
self.bec_dispatcher.connect_slot(
self.on_scan_queue_status, MessageEndpoints.scan_queue_status()
)
else:
self.bec_dispatcher.disconnect_slot(
self.on_scan_queue_status, MessageEndpoints.scan_queue_status()
)
return self._auto_updates
@Slot(dict, dict)
def on_scan_queue_status(self, msg, meta):
"""
Slot to handle scan queue status messages. Decides what update to perform based on the scan queue status.
Args:
msg(dict): Message from the BEC.
meta(dict): Metadata from the BEC.
"""
primary_queue = msg.get("queue").get("primary")
info = primary_queue.get("info", None)
if not info:
return
active_request_block = info[0].get("active_request_block", None)
if not active_request_block:
return
report_instructions = active_request_block.get("report_instructions", None)
if not report_instructions:
return
instruction_type = list(report_instructions[0].keys())[0]
if instruction_type == "scan_progress":
self._hook_scan_progress(ring_index=0)
elif instruction_type == "readback":
devices = report_instructions[0].get("readback").get("devices")
start = report_instructions[0].get("readback").get("start")
end = report_instructions[0].get("readback").get("end")
if self.config.num_bars != len(devices):
self.set_number_of_bars(len(devices))
for index, device in enumerate(devices):
self._hook_readback(index, device, start[index], end[index])
else:
logger.error(f"{instruction_type} not supported yet.")
def _hook_scan_progress(self, ring_index: int | None = None):
"""
Hook the scan progress to the progress bars.
Args:
ring_index(int): Index of the progress bar to hook the scan progress to.
"""
if ring_index is not None:
ring = self._find_ring_by_index(ring_index)
else:
ring = self._rings[0]
if ring.config.connections.slot == "on_scan_progress":
return
ring.set_connections("on_scan_progress", MessageEndpoints.scan_progress())
def _hook_readback(self, bar_index: int, device: str, min: float | int, max: float | int):
"""
Hook the readback values to the progress bars.
Args:
bar_index(int): Index of the progress bar to hook the readback values to.
device(str): Device to readback values from.
min(float|int): Minimum value for the progress bar.
max(float|int): Maximum value for the progress bar.
"""
ring = self._find_ring_by_index(bar_index)
ring.set_min_max_values(min, max)
endpoint = MessageEndpoints.device_readback(device)
ring.set_connections("on_device_readback", endpoint)
def _adjust_list_to_bars(self, items: list) -> list:
"""
Utility method to adjust the list of parameters to match the number of progress bars.
@@ -204,249 +550,101 @@ class RingProgressContainerWidget(QWidget):
)
if not isinstance(items, list):
items = [items]
if len(items) < self.num_bars:
if len(items) < self.config.num_bars:
last_item = items[-1]
items.extend([last_item] * (self.num_bars - len(items)))
elif len(items) > self.num_bars:
items = items[: self.num_bars]
items.extend([last_item] * (self.config.num_bars - len(items)))
elif len(items) > self.config.num_bars:
items = items[: self.config.num_bars]
return items
def _validate_index(self, index: int) -> int:
def _bar_index_check(self, bar_index: int):
"""
Check if the provided index is valid for the number of bars.
Utility method to check if the bar index is within the range of the number of progress bars.
Args:
index(int): Index to check.
Returns:
int: Validated index.
bar_index(int): Index of the progress bar to set the value for.
"""
try:
self.rings[index]
except IndexError:
raise IndexError(f"Index {index} is out of range for {self.num_bars} rings.")
return index
if not (0 <= bar_index < self.config.num_bars):
raise ValueError(
f"bar_index {bar_index} out of range of number of bars {self.config.num_bars}."
)
return bar_index
def paintEvent(self, event):
if not self._rings:
return
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
size = min(self.width(), self.height())
rect = QtCore.QRect(0, 0, size, size)
rect.adjust(
max(ring.config.line_width for ring in self._rings),
max(ring.config.line_width for ring in self._rings),
-max(ring.config.line_width for ring in self._rings),
-max(ring.config.line_width for ring in self._rings),
)
for i, ring in enumerate(self._rings):
# Background arc
painter.setPen(
QtGui.QPen(ring.background_color, ring.config.line_width, QtCore.Qt.SolidLine)
)
offset = self.config.gap * i
adjusted_rect = QtCore.QRect(
rect.left() + offset,
rect.top() + offset,
rect.width() - 2 * offset,
rect.height() - 2 * offset,
)
painter.drawArc(adjusted_rect, ring.config.start_position, 360 * 16)
# Foreground arc
pen = QtGui.QPen(ring.color, ring.config.line_width, QtCore.Qt.SolidLine)
pen.setCapStyle(QtCore.Qt.RoundCap)
painter.setPen(pen)
proportion = (ring.config.value - ring.config.min_value) / (
(ring.config.max_value - ring.config.min_value) + 1e-3
)
angle = int(proportion * 360 * 16 * ring.config.direction)
painter.drawArc(adjusted_rect, ring.start_position, angle)
def reset_diameter(self):
"""
Reset the fixed size of the widget.
"""
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
self.setMinimumSize(self._calculate_minimum_size())
self.setMaximumSize(16777215, 16777215)
def _calculate_minimum_size(self):
"""
Calculate the minimum size of the widget.
"""
if not self.config.rings:
logger.warning("no rings to get size from setting size to 10x10")
return QSize(10, 10)
ring_widths = [self.config.rings[i].line_width for i in range(self.config.num_bars)]
total_width = sum(ring_widths) + self.config.gap * (self.config.num_bars - 1)
diameter = max(total_width * 2, 50)
return QSize(diameter, diameter)
def sizeHint(self):
min_size = self._calculate_minimum_size()
return min_size
def clear_all(self):
"""
Clear all rings from the widget.
"""
for ring in self.rings:
ring.close()
ring.deleteLater()
self.rings = []
for ring in self._rings:
ring.reset_connection()
self._rings.clear()
self.update()
class RingProgressBar(BECWidget, QWidget):
ICON_NAME = "track_changes"
PLUGIN = True
RPC = True
USER_ACCESS = [
*BECWidget.USER_ACCESS,
"screenshot",
"rings",
"add_ring",
"remove_ring",
"set_gap",
"set_center_label",
]
def __init__(self, parent: QWidget | None = None, client=None, **kwargs):
super().__init__(parent=parent, client=client, theme_update=True, **kwargs)
self.setWindowTitle("Ring Progress Bar")
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.layout)
self.toolbar = ModularToolBar(self)
self._init_toolbar()
self.layout.addWidget(self.toolbar)
# Placeholder for the actual ring progress bar widget
self.ring_progress_bar = RingProgressContainerWidget(self)
self.layout.addWidget(self.ring_progress_bar)
self.settings_dialog = None
self.toolbar.show_bundles(["rpb_settings"])
def apply_theme(self, theme: str):
super().apply_theme(theme)
if self.ring_progress_bar.color_map:
self.ring_progress_bar.set_colors_from_map(self.ring_progress_bar.color_map)
def _init_toolbar(self):
settings_action = MaterialIconAction(
icon_name="settings",
tooltip="Show Ring Progress Bar Settings",
checkable=True,
parent=self,
)
self.toolbar.add_action("rpb_settings", settings_action)
settings_action.action.triggered.connect(self._open_settings_dialog)
def _open_settings_dialog(self):
""" "
Open the settings dialog for the ring progress bar.
"""
settings_action = self.toolbar.components.get_action("rpb_settings").action
if self.settings_dialog is None or not self.settings_dialog.isVisible():
settings = RingSettings(parent=self, target_widget=self, popup=True)
self.settings_dialog = SettingsDialog(
self,
settings_widget=settings,
window_title="Ring Progress Bar Settings",
modal=False,
)
self.settings_dialog.resize(900, 500)
self.settings_dialog.finished.connect(self._settings_dialog_closed)
self.settings_dialog.show()
settings_action.setChecked(True)
else:
# Dialog is already open, raise it
self.settings_dialog.raise_()
self.settings_dialog.activateWindow()
settings_action.setChecked(True)
def _settings_dialog_closed(self):
"""
Handle the settings dialog being closed.
"""
settings_action = self.toolbar.components.get_action("rpb_settings").action
settings_action.setChecked(False)
self.settings_dialog = None
#################################################
###### RPC User Access Methods ##################
#################################################
def add_ring(self, config: dict | None = None) -> Ring:
"""
Add a new ring to the ring progress bar.
Optionally, a configuration dictionary can be provided but the ring
can also be configured later. The config dictionary must provide
the qproperties of the Qt Ring object.
Args:
config(dict | None): Optional configuration dictionary for the ring.
Returns:
Ring: The newly added ring object.
"""
return self.ring_progress_bar.add_ring(config=config)
def remove_ring(self, index: int | None = None):
"""
Remove a ring from the ring progress bar.
Args:
index(int | None): Index of the ring to remove. If None, removes the last ring.
"""
if self.ring_progress_bar.num_bars == 0:
return
self.ring_progress_bar.remove_ring(index=index)
def set_gap(self, value: int):
"""
Set the gap between rings.
Args:
value(int): Gap value in pixels.
"""
self.gap = value
def set_center_label(self, text: str):
"""
Set the center label text.
Args:
text(str): Text for the center label.
"""
self.center_label = text
@property
def rings(self) -> list[Ring]:
return self.ring_progress_bar.rings
###############################################
####### QProperties ###########################
###############################################
@SafeProperty(int)
def gap(self) -> int:
return self.ring_progress_bar.gap
@gap.setter
def gap(self, value: int):
self.ring_progress_bar.gap = value
self.ring_progress_bar.update()
@SafeProperty(str)
def color_map(self) -> str:
return self.ring_progress_bar.color_map or ""
@color_map.setter
def color_map(self, colormap: str):
if colormap == "":
self.ring_progress_bar.color_map = ""
return
if colormap not in pg.colormap.listMaps():
return
self.ring_progress_bar.set_colors_from_map(colormap)
self.ring_progress_bar.color_map = colormap
@SafeProperty(str)
def center_label(self) -> str:
return self.ring_progress_bar.center_label.text()
@center_label.setter
def center_label(self, text: str):
self.ring_progress_bar.center_label.setText(text)
@SafeProperty(str, designable=False, popup_error=True)
def ring_json(self) -> str:
"""
A JSON string property that serializes all ring pydantic configs.
"""
raw_list = []
for ring in self.rings:
cfg_dict = ring.config.model_dump()
raw_list.append(cfg_dict)
return json.dumps(raw_list, indent=2)
@ring_json.setter
def ring_json(self, json_data: str):
"""
Load rings from a JSON string and add them to the ring progress bar.
"""
try:
ring_configs = json.loads(json_data)
self.ring_progress_bar.clear_all()
for cfg_dict in ring_configs:
self.add_ring(config=cfg_dict)
except json.JSONDecodeError as e:
logger.error(f"Failed to decode JSON: {e}")
self.initialize_bars()
def cleanup(self):
self.ring_progress_bar.clear_all()
self.ring_progress_bar.close()
self.ring_progress_bar.deleteLater()
self.bec_dispatcher.disconnect_slot(
self.on_scan_queue_status, MessageEndpoints.scan_queue_status()
)
for ring in self._rings:
self._cleanup_ring(ring)
self._rings.clear()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import apply_theme
app = QApplication(sys.argv)
apply_theme("dark")
widget = RingProgressBar()
widget.show()
sys.exit(app.exec_())

View File

@@ -51,7 +51,7 @@ class RingProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "RingProgressBar"
def toolTip(self):
return "RingProgressBar"
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -1,509 +0,0 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils import UILoader
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import (
RingProgressBar,
RingProgressContainerWidget,
)
class RingCardWidget(QFrame):
def __init__(self, ring: Ring, container: RingProgressContainerWidget, parent=None):
super().__init__(parent)
self.ring = ring
self.container = container
self.details_visible = False
self.setProperty("skip_settings", True)
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setObjectName("RingCardWidget")
bg = self._get_theme_color("BORDER")
self.setStyleSheet(
f"""
#RingCardWidget {{
border: 1px solid {bg.name() if bg else '#CCCCCC'};
border-radius: 4px;
}}
"""
)
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(6)
self._init_header(layout)
self._init_details(layout)
self._init_values()
self._connect_signals()
self.mode_combo.setCurrentText(self._get_display_mode_string(self.ring.config.mode))
self._set_widget_mode_enabled(self.ring.config.mode)
def _get_theme_color(self, color_name: str) -> QColor | None:
app = QApplication.instance()
if not app:
return
if not app.theme:
return
return app.theme.color(color_name)
def _init_header(self, parent_layout: QVBoxLayout):
"""Create the collapsible header with basic controls"""
header = QWidget()
layout = QHBoxLayout(header)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
self.expand_btn = QPushButton("")
self.expand_btn.setFixedWidth(24)
self.expand_btn.clicked.connect(self.toggle_details)
self.mode_combo = QComboBox()
self.mode_combo.addItems(["Manual", "Scan Progress", "Device Readback"])
self.mode_combo.currentTextChanged.connect(self._update_mode)
delete_btn = QPushButton(material_icon("delete"), "")
color = self._get_theme_color("ACCENT_HIGHLIGHT")
delete_btn.setStyleSheet(f"background-color: {color.name() if color else '#CC181E'}")
delete_btn.clicked.connect(self._delete_self)
layout.addWidget(self.expand_btn)
layout.addWidget(QLabel("Mode"))
layout.addWidget(self.mode_combo)
layout.addStretch()
layout.addWidget(delete_btn)
parent_layout.addWidget(header)
def _init_details(self, parent_layout: QVBoxLayout):
"""Create the collapsible details area with the UI file"""
self.details = QWidget()
self.details.setVisible(False)
details_layout = QVBoxLayout(self.details)
details_layout.setContentsMargins(0, 0, 0, 0)
# Load UI file into details area
current_path = os.path.dirname(__file__)
self.ui = UILoader().load_ui(os.path.join(current_path, "ring_settings.ui"), self.details)
details_layout.addWidget(self.ui)
parent_layout.addWidget(self.details)
def toggle_details(self):
"""Toggle visibility of the details area"""
self.details_visible = not self.details_visible
self.details.setVisible(self.details_visible)
self.expand_btn.setText("" if self.details_visible else "")
# --------------------------------------------------------
def _connect_signals(self):
"""Connect UI signals to ring methods"""
# Data connections
self.ui.value_spin_box.valueChanged.connect(self.ring.set_value)
self.ui.min_spin_box.valueChanged.connect(self._update_min_max)
self.ui.max_spin_box.valueChanged.connect(self._update_min_max)
# Config connections
self.ui.start_angle_spin_box.valueChanged.connect(self.ring.set_start_angle)
self.ui.direction_combo_box.currentIndexChanged.connect(self._update_direction)
self.ui.line_width_spin_box.valueChanged.connect(self.ring.set_line_width)
self.ui.background_color_button.color_changed.connect(self.ring.set_background)
self.ui.ring_color_button.color_changed.connect(self._on_ring_color_changed)
self.ui.device_combo_box.device_selected.connect(self._on_device_changed)
self.ui.signal_combo_box.device_signal_changed.connect(self._on_signal_changed)
def _init_values(self):
"""Initialize UI values from ring config"""
# Data values
self.ui.value_spin_box.setRange(-1e6, 1e6)
self.ui.value_spin_box.setValue(self.ring.config.value)
self.ui.min_spin_box.setRange(-1e6, 1e6)
self.ui.min_spin_box.setValue(self.ring.config.min_value)
self.ui.max_spin_box.setRange(-1e6, 1e6)
self.ui.max_spin_box.setValue(self.ring.config.max_value)
self._update_min_max()
self.ui.device_combo_box.setEditable(True)
self.ui.signal_combo_box.setEditable(True)
device, signal = self.ring.config.device, self.ring.config.signal
if device:
self.ui.device_combo_box.set_device(device)
if signal:
for i in range(self.ui.signal_combo_box.count()):
data_item = self.ui.signal_combo_box.itemData(i)
if data_item and data_item.get("obj_name") == signal:
self.ui.signal_combo_box.setCurrentIndex(i)
break
# Config values
self.ui.start_angle_spin_box.setValue(self.ring.config.start_position)
self.ui.direction_combo_box.setCurrentIndex(0 if self.ring.config.direction == -1 else 1)
self.ui.line_width_spin_box.setRange(1, 100)
self.ui.line_width_spin_box.setValue(self.ring.config.line_width)
# Colors
self.ui.ring_color_button.set_color(self.ring.color)
self.ui.color_sync_button.setCheckable(True)
self.ui.color_sync_button.setChecked(self.ring.config.link_colors)
# Set initial button state based on link_colors
if self.ring.config.link_colors:
self.ui.color_sync_button.setIcon(material_icon("link"))
self.ui.color_sync_button.setToolTip(
"Colors are linked - background derives from main color"
)
self.ui.background_color_button.setEnabled(False)
self.ui.background_color_label.setEnabled(False)
# Trigger sync to ensure background color is derived from main color
self.ring.set_color(self.ring.config.color)
self.ui.background_color_button.set_color(self.ring.background_color)
else:
self.ui.color_sync_button.setIcon(material_icon("link_off"))
self.ui.color_sync_button.setToolTip(
"Colors are unlinked - set background independently"
)
self.ui.background_color_button.setEnabled(True)
self.ui.background_color_label.setEnabled(True)
self.ui.background_color_button.set_color(self.ring.background_color)
self.ui.color_sync_button.toggled.connect(self._toggle_color_link)
# --------------------------------------------------------
def _toggle_color_link(self, checked: bool):
"""Toggle the color linking between main and background color"""
self.ring.config.link_colors = checked
# Update button icon and tooltip based on state
if checked:
self.ui.color_sync_button.setIcon(material_icon("link"))
self.ui.color_sync_button.setToolTip(
"Colors are linked - background derives from main color"
)
# Trigger background color update by calling set_color
self.ring.set_color(self.ring.config.color)
# Update UI to show the new background color
self.ui.background_color_button.set_color(self.ring.background_color)
else:
self.ui.color_sync_button.setIcon(material_icon("link_off"))
self.ui.color_sync_button.setToolTip(
"Colors are unlinked - set background independently"
)
# Enable/disable background color controls based on link state
self.ui.background_color_button.setEnabled(not checked)
self.ui.background_color_label.setEnabled(not checked)
def _on_ring_color_changed(self, color: QColor):
"""Handle ring color changes and update background if colors are linked"""
self.ring.set_color(color)
# If colors are linked, update the background color button to show the new derived color
if self.ring.config.link_colors:
self.ui.background_color_button.set_color(self.ring.background_color)
def _update_min_max(self):
self.ui.value_spin_box.setRange(self.ui.min_spin_box.value(), self.ui.max_spin_box.value())
self.ring.set_min_max_values(self.ui.min_spin_box.value(), self.ui.max_spin_box.value())
def _update_direction(self, index: int):
self.ring.config.direction = -1 if index == 0 else 1
self.ring.update()
@SafeSlot(str)
def _on_device_changed(self, device: str):
signal = self.ui.signal_combo_box.get_signal_name()
self.ring.set_update("device", device=device, signal=signal)
self.ring.config.device = device
@SafeSlot(str)
def _on_signal_changed(self, signal: str):
device = self.ui.device_combo_box.currentText()
signal = self.ui.signal_combo_box.get_signal_name()
if not device or device not in self.container.bec_dispatcher.client.device_manager.devices:
return
self.ring.set_update("device", device=device, signal=signal)
self.ring.config.signal = signal
def _unify_mode_string(self, mode: str) -> str:
"""Convert mode string to a unified format"""
mode = mode.lower()
if mode == "scan progress":
return "scan"
if mode == "device readback":
return "device"
return mode
def _get_display_mode_string(self, mode: str) -> str:
"""Convert mode string to display format"""
match mode:
case "manual":
return "Manual"
case "scan":
return "Scan Progress"
case "device":
return "Device Readback"
return mode.capitalize()
def _update_mode(self, mode: str):
"""Update the ring's mode based on combo box selection"""
mode = self._unify_mode_string(mode)
match mode:
case "manual":
self.ring.set_update("manual")
case "scan":
self.ring.set_update("scan")
case "device":
self.ring.set_update("device", device=self.ui.device_combo_box.currentText())
self._set_widget_mode_enabled(mode)
def _set_widget_mode_enabled(self, mode: str):
"""Show/hide controls based on the current mode"""
mode = self._unify_mode_string(mode)
self.ui.device_combo_box.setEnabled(mode == "device")
self.ui.signal_combo_box.setEnabled(mode == "device")
self.ui.device_label.setEnabled(mode == "device")
self.ui.signal_label.setEnabled(mode == "device")
self.ui.min_label.setEnabled(mode in ["manual", "device"])
self.ui.max_label.setEnabled(mode in ["manual", "device"])
self.ui.value_label.setEnabled(mode == "manual")
self.ui.value_spin_box.setEnabled(mode == "manual")
self.ui.min_spin_box.setEnabled(mode in ["manual", "device"])
self.ui.max_spin_box.setEnabled(mode in ["manual", "device"])
def _delete_self(self):
"""Delete this ring from the container"""
if self.ring in self.container.rings:
self.container.rings.remove(self.ring)
self.ring.deleteLater()
self.cleanup()
def cleanup(self):
"""Cleanup the card widget"""
self.ui.device_combo_box.close()
self.ui.device_combo_box.deleteLater()
self.ui.signal_combo_box.close()
self.ui.signal_combo_box.deleteLater()
self.close()
self.deleteLater()
# ============================================================
# Ring settings widget
# ============================================================
class RingSettings(SettingWidget):
def __init__(
self, parent=None, target_widget: RingProgressBar | None = None, popup=False, **kwargs
):
super().__init__(parent=parent, **kwargs)
self.setProperty("skip_settings", True)
self.target_widget = target_widget
self.popup = popup
if not target_widget:
return
self.container: RingProgressContainerWidget = target_widget.ring_progress_bar
self.original_num_bars = len(self.container.rings)
self.original_configs = [ring.config.model_dump() for ring in self.container.rings]
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10)
add_button = QPushButton(material_icon("add"), "Add Ring")
add_button.clicked.connect(self.add_ring)
self.center_label_edit = QLineEdit(self.container.center_label.text())
self.center_label_edit.setPlaceholderText("Center Label")
self.center_label_edit.textChanged.connect(self._update_center_label)
self.colormap_toggle = QPushButton()
self.colormap_toggle.setCheckable(True)
self.colormap_toggle.setIcon(material_icon("palette"))
self.colormap_toggle.setToolTip(
f"Colormap mode is {'enabled' if self.container.color_map else 'disabled'}"
)
self.colormap_toggle.toggled.connect(self._toggle_colormap_mode)
self.colormap_button = BECColorMapWidget(parent=self)
self.colormap_button.setToolTip("Set a global colormap for all rings")
self.colormap_button.colormap_changed_signal.connect(self._set_global_colormap)
toolbar = QHBoxLayout()
toolbar.addWidget(add_button)
toolbar.addWidget(self.center_label_edit)
toolbar.addStretch()
toolbar.addWidget(self.colormap_toggle)
toolbar.addWidget(self.colormap_button)
layout.addLayout(toolbar)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.cards_container = QWidget()
self.cards_layout = QVBoxLayout(self.cards_container)
self.cards_layout.setSpacing(10)
self.cards_layout.addStretch()
self.scroll.setWidget(self.cards_container)
layout.addWidget(self.scroll)
self.refresh_from_container()
self.original_label = self.container.center_label.text()
def sizeHint(self) -> QSize:
return QSize(720, 520)
def refresh_from_container(self):
if not self.container:
return
for ring in self.container.rings:
card = RingCardWidget(ring, self.container)
self.cards_layout.insertWidget(self.cards_layout.count() - 1, card)
if self.container.color_map:
self.colormap_button.colormap = self.container.color_map
self.colormap_toggle.setChecked(bool(self.container.color_map))
@SafeSlot()
def add_ring(self):
if not self.container:
return
self.container.add_ring()
ring = self.container.rings[len(self.container.rings) - 1]
if ring:
card = RingCardWidget(ring, self.container)
self.cards_layout.insertWidget(self.cards_layout.count() - 1, card)
# If a global colormap is set, apply it
if self.container.color_map:
self._toggle_colormap_mode(bool(self.container.color_map))
@SafeSlot(str)
def _update_center_label(self, text: str):
if not self.container:
return
self.container.center_label.setText(text)
@SafeSlot(bool)
def _toggle_colormap_mode(self, enabled: bool):
self.colormap_toggle.setToolTip(f"Colormap mode is {'enabled' if enabled else 'disabled'}")
if enabled:
colormap = self.colormap_button.colormap
self._set_global_colormap(colormap)
else:
self.container.color_map = ""
for i in range(self.cards_layout.count() - 1): # -1 to exclude the stretch
widget = self.cards_layout.itemAt(i).widget()
if not isinstance(widget, RingCardWidget):
continue
widget.ui.ring_color_button.setEnabled(not enabled)
widget.ui.ring_color_button.setToolTip(
"Disabled in colormap mode" if enabled else "Set the ring color"
)
widget.ui.ring_color_label.setEnabled(not enabled)
widget.ui.background_color_button.setEnabled(
not enabled and not widget.ring.config.link_colors
)
widget.ui.color_sync_button.setEnabled(not enabled)
@SafeSlot(str)
def _set_global_colormap(self, colormap: str):
if not self.container:
return
self.container.set_colors_from_map(colormap)
# Update all ring card color buttons to reflect the new colors
for i in range(self.cards_layout.count() - 1): # -1 to exclude the stretch
widget = self.cards_layout.itemAt(i).widget()
if not isinstance(widget, RingCardWidget):
continue
widget.ui.ring_color_button.set_color(widget.ring.color)
if widget.ring.config.link_colors:
widget.ui.background_color_button.set_color(widget.ring.background_color)
@SafeSlot()
def accept_changes(self):
if not self.container:
return
self.original_configs = [ring.config.model_dump() for ring in self.container.rings]
for i, ring in enumerate(self.container.rings):
ring.setGeometry(self.container.rect())
ring.gap = self.container.gap * i
ring.show() # Ensure ring is visible
ring.raise_() # Bring ring to front
self.container.center_label.setText(self.center_label_edit.text())
self.original_label = self.container.center_label.text()
self.original_num_bars = len(self.container.rings)
self.container.update()
def cleanup(self):
"""
Cleanup the settings widget.
"""
# Remove any rings that were added but not applied
if not self.container:
return
if len(self.container.rings) > self.original_num_bars:
remove_rings = self.container.rings[self.original_num_bars :]
for ring in remove_rings:
self.container.rings.remove(ring)
ring.deleteLater()
rings_to_add = max(0, self.original_num_bars - len(self.container.rings))
for _ in range(rings_to_add):
self.container.add_ring()
# apply original configs to all rings
for i, ring in enumerate(self.container.rings):
ring.config = ring.config.model_validate(self.original_configs[i])
for i in range(self.cards_layout.count()):
item = self.cards_layout.itemAt(i)
if not item or not item.widget():
continue
widget: RingCardWidget = item.widget()
widget.cleanup()
self.container.update()
self.container.center_label.setText(self.original_label)

View File

@@ -1,235 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>731</width>
<height>199</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QGroupBox" name="data_group_box">
<property name="title">
<string>Data</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="1" column="0">
<widget class="QDoubleSpinBox" name="value_spin_box"/>
</item>
<item row="3" column="1" colspan="2">
<widget class="DeviceComboBox" name="device_combo_box"/>
</item>
<item row="0" column="0">
<widget class="QLabel" name="value_label">
<property name="text">
<string>Value</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLabel" name="max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="max_spin_box"/>
</item>
<item row="4" column="0">
<widget class="QLabel" name="signal_label">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="min_spin_box"/>
</item>
<item row="4" column="1" colspan="2">
<widget class="SignalComboBox" name="signal_combo_box"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="device_label">
<property name="text">
<string>Device</string>
</property>
</widget>
</item>
<item row="2" column="0" colspan="3">
<widget class="Line" name="data_hor_line">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="0" column="1">
<widget class="QGroupBox" name="config_group_box">
<property name="title">
<string>Config</string>
</property>
<layout class="QGridLayout" name="gridLayout_3">
<item row="2" column="0" colspan="3">
<widget class="Line" name="config_hor_line">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QSpinBox" name="start_angle_spin_box">
<property name="suffix">
<string>°</string>
</property>
<property name="maximum">
<number>360</number>
</property>
<property name="value">
<number>90</number>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLabel" name="line_width_label">
<property name="text">
<string>Line Width</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="direction_combo_box">
<item>
<property name="text">
<string>Clockwise</string>
</property>
</item>
<item>
<property name="text">
<string>Counter-clockwise</string>
</property>
</item>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="direction_label">
<property name="text">
<string>Direction</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QSpinBox" name="line_width_spin_box">
<property name="value">
<number>12</number>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="start_angle_label">
<property name="text">
<string>Start Angle</string>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="ColorButtonNative" name="background_color_button"/>
</item>
<item row="4" column="1">
<widget class="ColorButtonNative" name="ring_color_button"/>
</item>
<item row="5" column="0">
<widget class="QLabel" name="background_color_label">
<property name="text">
<string>Background Color</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="ring_color_label">
<property name="text">
<string>Ring Color</string>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="QToolButton" name="color_sync_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ColorButtonNative</class>
<extends></extends>
<header>color_button_native</header>
</customwidget>
<customwidget>
<class>DeviceComboBox</class>
<extends></extends>
<header>device_combo_box</header>
</customwidget>
<customwidget>
<class>SignalComboBox</class>
<extends></extends>
<header>signal_combo_box</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>device_combo_box</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>signal_combo_box</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>209</x>
<y>133</y>
</hint>
<hint type="destinationlabel">
<x>213</x>
<y>153</y>
</hint>
</hints>
</connection>
<connection>
<sender>device_combo_box</sender>
<signal>device_reset()</signal>
<receiver>signal_combo_box</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>135</y>
</hint>
<hint type="destinationlabel">
<x>250</x>
<y>147</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -38,8 +38,6 @@ class CommunicateConfigAction(QRunnable):
self._process(
{"action": self.action, "config": self.config, "wait_for_response": False}
)
elif self.action == "cancel":
self._process_cancel()
elif self.action in ["add", "update", "remove"]:
if (dev_name := self.device or self.config.get("name")) is None:
raise ValueError(
@@ -75,13 +73,6 @@ class CommunicateConfigAction(QRunnable):
self.config_helper.handle_update_reply(reply, RID, timeout)
logger.info("Done updating config!")
def _process_cancel(self):
logger.info("Cancelling ongoing configuration operation")
self.config_helper.send_config_request(
action="cancel", config=None, wait_for_response=True, timeout_s=10
)
logger.info("Done cancelling configuration operation")
def process_remove_readd(self, dev_name: str):
logger.info(f"Removing and readding device: {dev_name}")
self.process_simple_action(dev_name, "remove")

View File

@@ -324,10 +324,12 @@ class DirectUpdateDeviceConfigDialog(BECWidget, DeviceConfigDialog):
def _start_waiting_display(self):
self._overlay_widget.setVisible(True)
self._spinner.start()
QApplication.processEvents() # TODO check if this kills performance and scheduling!
def _stop_waiting_display(self):
self._overlay_widget.setVisible(False)
self._spinner.stop()
QApplication.processEvents() # TODO check if this kills performance and scheduling!
def main(): # pragma: no cover

View File

@@ -15,7 +15,6 @@ class SignalDisplay(BECWidget, QWidget):
def __init__(
self,
parent=None,
client=None,
device: str = "",
config: ConnectionConfig = None,
@@ -25,14 +24,7 @@ class SignalDisplay(BECWidget, QWidget):
):
"""A widget to display all the signals from a given device, and allow getting
a fresh reading."""
super().__init__(
parent=parent,
client=client,
config=config,
gui_id=gui_id,
theme_update=theme_update,
**kwargs,
)
super().__init__(client, config, gui_id, theme_update, **kwargs)
self.get_bec_shortcuts()
self._layout = QVBoxLayout()
self.setLayout(self._layout)
@@ -80,7 +72,6 @@ class SignalDisplay(BECWidget, QWidget):
]:
self._content_layout.addWidget(
SignalLabel(
parent=self,
device=self._device,
signal=sig,
show_select_button=False,
@@ -90,7 +81,6 @@ class SignalDisplay(BECWidget, QWidget):
else:
self._content_layout.addWidget(
SignalLabel(
parent=self,
device=self._device,
signal=self._device,
show_select_button=False,

View File

@@ -177,10 +177,12 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
def _start_waiting_display(self):
self._overlay_widget.setVisible(True)
self._spinner.start()
QtWidgets.QApplication.processEvents()
def _stop_waiting_display(self):
self._overlay_widget.setVisible(False)
self._spinner.stop()
QtWidgets.QApplication.processEvents()
def _current_item_changed(
self, current: QtWidgets.QTreeWidgetItem, previous: QtWidgets.QTreeWidgetItem

View File

@@ -494,7 +494,6 @@ if __name__ == "__main__":
w.setLayout(QVBoxLayout())
w.layout().addWidget(
SignalLabel(
parent=w,
device="samx",
signal="readback",
custom_label="custom label:",
@@ -502,9 +501,7 @@ if __name__ == "__main__":
show_select_button=False,
)
)
w.layout().addWidget(
SignalLabel(parent=w, device="samy", signal="readback", show_default_units=True)
)
w.layout().addWidget(SignalLabel(device="samy", signal="readback", show_default_units=True))
l = SignalLabel()
l.device = "bpm4i"
l.signal = "bpm4i"

View File

@@ -32,7 +32,7 @@ dock_area = gui.new()
img_widget = dock_area.new().new(gui.available_widgets.Image)
# Add an ImageWidget to the BECFigure for a 2D detector
img_widget.image(device_name='eiger', device_entry='preview')
img_widget.image(monitor='eiger', monitor_type='2d')
img_widget.title = "Camera Image - Eiger Detector"
```
@@ -46,7 +46,7 @@ dock_area = gui.new()
img_widget = dock_area.new().new(gui.available_widgets.Image)
# Add an ImageWidget to the BECFigure for a 2D detector
img_widget.image(device_name='waveform', device_entry='data')
img_widget.image(monitor='waveform', monitor_type='1d')
img_widget.title = "Line Detector Data"
# Optional: Set the color map and value range
@@ -84,7 +84,7 @@ The Image Widget can be configured for different detectors by specifying the cor
```python
# For a 2D camera detector
img_widget = fig.image(device_name='eiger', device_entry='preview')
img_widget = fig.image(monitor='eiger', monitor_type='2d')
img_widget.set_title("Eiger Camera Image")
```
@@ -92,7 +92,7 @@ img_widget.set_title("Eiger Camera Image")
```python
# For a 1D line detector
img_widget = fig.image(device_name='waveform', device_entry='data')
img_widget = fig.image(monitor='waveform', monitor_type='1d')
img_widget.set_title("Line Detector Data")
```

View File

@@ -24,13 +24,11 @@ In this example, we demonstrate how to add a `RingProgressBar` widget to a `BECD
```python
# Add a new dock with a RingProgressBar widget
dock_area = gui.new() # Create a new dock area
progress = dock_area.new(gui.available_widgets.RingProgressBar)
dock_area = gui.new('my_new_dock_area') # Create a new dock area
progress = dock_area.new().new(gui.available_widgets.RingProgressBar)
# Add a ring to the RingProgressBar
progress.add_ring()
ring = progress.rings[0]
ring.set_value(50) # Set the progress value to 50
# Customize the size of the progress ring
progress.set_line_widths(20)
```
## Example 2 - Adding Multiple Rings to Track Parallel Tasks
@@ -42,7 +40,8 @@ By default, the `RingProgressBar` widget displays a single ring. You can add add
progress.add_ring()
# Customize the rings
progress.rings[1].set_value(30) # Set the second ring to 30
progress.rings[0].set_line_widths(20) # Set the width of the first ring
progress.rings[1].set_line_widths(10) # Set the width of the second ring
```
## Example 3 - Integrating with Device Readback and Scans
@@ -57,6 +56,44 @@ progress.rings[0].set_update("scan")
progress.rings[1].set_update("device", "samx")
```
## Example 4 - Customizing Visual Elements of the Rings
The `RingProgressBar` widget offers various customization options, such as changing colors, line widths, and the gap between rings.
```python
# Set the color of the first ring to blue
progress.rings[0].set_color("blue")
# Set the background color of the second ring
progress.rings[1].set_background("gray")
# Adjust the gap between the rings
progress.set_gap(5)
# Set the diameter of the progress bar
progress.set_diameter(150)
```
## Example 5 - Manual Updates and Precision Control
While the `RingProgressBar` supports automatic updates, you can also manually control the progress and set the precision for each ring.
```python
# Disable automatic updates and manually set the progress value
progress.enable_auto_updates(False)
progress.rings[0].set_value(75) # Set the first ring to 75%
# Set precision for the progress display
progress.set_precision(2) # Display progress with two decimal places
# Setting multiple rigns with different values
progress.set_number_of_bars(3)
# Set the values of the rings to 50, 75, and 25 from outer to inner ring
progress.set_value([50, 75, 25])
```
````
````{tab} API

View File

@@ -59,7 +59,7 @@ def test_rpc_add_dock_with_plots_e2e(qtbot, bec_client_lib, connected_client_gui
mm.map("samx", "samy")
curve = wf.plot(x_name="samx", y_name="bpm4i")
im_item = im.image(device_name="eiger", device_entry="preview")
im_item = im.image("eiger")
assert curve.__class__.__name__ == "RPCReference"
assert curve.__class__ == RPCReference
@@ -122,12 +122,12 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
assert gui.windows["bec"] is gui.bec
mw = gui.bec
assert mw.__class__.__name__ == "RPCReference"
assert gui._ipython_registry[mw._gui_id].__class__.__name__ == "BECDockArea"
assert gui._ipython_registry[mw._gui_id].__class__.__name__ == "AdvancedDockArea"
xw = gui.new("X")
xw.delete_all()
assert xw.__class__.__name__ == "RPCReference"
assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "BECDockArea"
assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "AdvancedDockArea"
assert len(gui.windows) == 2
assert gui._gui_is_alive()

View File

@@ -42,7 +42,7 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj):
c3 = wf.plot(y=[1, 2, 3], x=[1, 2, 3])
assert c3.object_name == "Curve_0"
im.image(device_name="eiger", device_entry="preview")
im.image(monitor="eiger")
mm.map(x_name="samx", y_name="samy")
sw.plot(x_name="samx", y_name="samy", z_name="bpm4a")
mw.plot(monitor="waveform")
@@ -165,14 +165,14 @@ def test_rpc_image(qtbot, bec_client_lib, connected_client_gui_obj):
scans = client.scans
im = dock_area.new("Image")
im.image(device_name="eiger", device_entry="preview")
im.image(monitor="eiger")
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
status.wait()
last_image_device = client.connector.get_last(
MessageEndpoints.device_preview("eiger", "preview")
)["data"].data
last_image_device = client.connector.get_last(MessageEndpoints.device_monitor_2d("eiger"))[
"data"
].data
last_image_plot = im.main_image.get_data()
# check plotted data

View File

@@ -15,7 +15,7 @@ def test_rpc_reference_objects(connected_client_gui_obj):
plt.plot(x_name="samx", y_name="bpm4i")
im = dock_area.new("Image")
im.image(device_name="eiger", device_entry="preview")
im.image("eiger")
motor_map = dock_area.new("MotorMap")
motor_map.map("samx", "samy")
plt_z = dock_area.new("Waveform")
@@ -23,8 +23,7 @@ def test_rpc_reference_objects(connected_client_gui_obj):
assert len(plt_z.curves) == 1
assert len(plt.curves) == 1
assert im.device_name == "eiger"
assert im.device_entry == "preview"
assert im.monitor == "eiger"
assert isinstance(im.main_image, RPCReference)
image_item = gui._ipython_registry.get(im.main_image._gui_id, None)

View File

@@ -74,15 +74,15 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
"""This test checks that all widgets that are available via gui.available_widgets can be created and removed."""
gui = connected_client_gui_obj
dock_area = gui.bec
# Number of top level widgets, should be 5
top_level_widgets_count = 13
# Number of top level widgets, should be 4
top_level_widgets_count = 12
assert len(gui._server_registry) == top_level_widgets_count
names = set(list(gui._server_registry.keys()))
# Number of widgets with parent_id == None, should be 3
# Number of widgets with parent_id == None, should be 2
widgets = [
widget for widget in gui._server_registry.values() if widget["config"]["parent_id"] is None
]
assert len(widgets) == 3
assert len(widgets) == 2
# Test all relevant widgets
for object_name in gui.available_widgets.__dict__:
@@ -115,43 +115,40 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
for widget in gui._server_registry.values()
if widget["config"]["parent_id"] is None
]
assert len(widgets) == 3
assert len(widgets) == 2
#############################
####### Remove widget #######
#############################
# Now we remove the widget again
widget_id = widget._gui_id
widget.remove()
# Wait for namespace to change
wait_for_namespace_change(
qtbot, gui, dock_area, widget.object_name, widget_id, exists=False
)
# Assert that widget is removed from the ipython registry and the namespace
assert hasattr(dock_area, widget.object_name) is False
# Client registry
assert gui._ipython_registry.get(widget_id, None) is None
# Server registry
assert gui._server_registry.get(widget_id, None) is None
# Check that the number of top level widgets is still the same. As the cleanup is done by the
# qt event loop, we need to wait for the qtbot to finish the cleanup
try:
# Now we remove the widget again
widget_id = widget._gui_id
widget.remove()
# Wait for namespace to change
wait_for_namespace_change(
qtbot, gui, dock_area, widget.object_name, widget_id, exists=False
)
# Assert that widget is removed from the ipython registry and the namespace
assert hasattr(dock_area, widget.object_name) is False
# Client registry
assert gui._ipython_registry.get(widget_id, None) is None
# Server registry
assert gui._server_registry.get(widget_id, None) is None
# Check that the number of top level widgets is still the same. As the cleanup is done by the
# qt event loop, we need to wait for the qtbot to finish the cleanup
try:
qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count)
except Exception as exc:
raise RuntimeError(
f"Widget {object_name} was not removed properly. The number of top level widgets "
f"is {len(gui._server_registry)} instead of {top_level_widgets_count}. The following "
f"widgets are not cleaned up: {set(gui._server_registry.keys()) - names}"
) from exc
# Number of widgets with parent_id == None, should be 2
widgets = [
widget
for widget in gui._server_registry.values()
if widget["config"]["parent_id"] is None
]
assert len(widgets) == 2
except Exception as e:
raise RuntimeError(f"Failed to remove widget {object_name}") from e
qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count)
except Exception as exc:
raise RuntimeError(
f"Widget {object_name} was not removed properly. The number of top level widgets "
f"is {len(gui._server_registry)} instead of {top_level_widgets_count}. The following "
f"widgets are not cleaned up: {set(gui._server_registry.keys()) - names}"
) from exc
# Number of widgets with parent_id == None, should be 2
widgets = [
widget
for widget in gui._server_registry.values()
if widget["config"]["parent_id"] is None
]
assert len(widgets) == 2

View File

@@ -16,7 +16,6 @@ from typing import TYPE_CHECKING
import numpy as np
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
@@ -222,6 +221,95 @@ def test_widgets_e2e_device_browser(qtbot, connected_client_gui_obj, random_gene
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_device_combo_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the DeviceComboBox widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.DeviceComboBox)
widget: client.DeviceComboBox
assert "samx" in widget.devices
assert "bpm4i" in widget.devices
widget.set_device("samx")
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_device_line_edit(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the DeviceLineEdit widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.DeviceLineEdit)
widget: client.DeviceLineEdit
assert widget._is_valid_input is False
assert "samx" in widget.devices
assert "bpm4i" in widget.devices
widget.set_device("samx")
assert widget._is_valid_input is True
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_signal_line_edit(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the DeviceSignalLineEdit widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.SignalLineEdit)
widget: client.SignalLineEdit
widget.set_device("samx")
assert widget._is_valid_input is False
assert widget.signals == [
"readback",
"setpoint",
"motor_is_moving",
"velocity",
"acceleration",
"tolerance",
]
widget.set_signal("readback")
assert widget._is_valid_input is True
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_signal_combobox(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the DeviceSignalComboBox widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox)
widget: client.SignalComboBox
widget.set_device("samx")
info = bec.device_manager.devices.samx._info["signals"]
assert widget.signals == [
["samx (readback)", info.get("readback")],
["setpoint", info.get("setpoint")],
["motor_is_moving", info.get("motor_is_moving")],
["velocity", info.get("velocity")],
["acceleration", info.get("acceleration")],
["tolerance", info.get("tolerance")],
]
widget.set_signal("samx (readback)")
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the Image widget."""
@@ -234,7 +322,7 @@ def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_fro
scans = bec.scans
dev = bec.device_manager.devices
# Test rpc calls
img = widget.image(device_name=dev.eiger.name, device_entry="preview")
img = widget.image(dev.eiger)
assert img.get_data() is None
# Run a scan and plot the image
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
@@ -248,13 +336,13 @@ def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_fro
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
# Check that last image is equivalent to data in Redis
last_img = bec.connector.get_last(MessageEndpoints.device_preview("eiger", "preview"))[
"data"
].data
last_img = bec.device_monitor.get_data(
dev.eiger, count=1
) # Get last image from Redis monitor 2D endpoint
assert np.allclose(img.get_data(), last_img)
# Now add a device with a preview signal
img = widget.image(device_name="eiger", device_entry="preview")
img = widget.image(["eiger", "preview"])
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
s.wait()
@@ -449,32 +537,30 @@ def test_widgets_e2e_positioner_control_line(
# TODO passes locally, fails on CI for some reason... -> issue #1003
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the RingProgressBar widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.RingProgressBar)
widget: client.RingProgressBar
widget.add_ring()
widget.add_ring()
widget.add_ring()
widget.rings[0].set_update("manual")
widget.rings[0].set_value(30)
widget.rings[0].set_min_max_values(0, 100)
widget.rings[1].set_update("scan")
widget.rings[2].set_update("device", device="samx")
# Test rpc calls
dev = bec.device_manager.devices
scans = bec.scans
# Do a scan
scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False).wait()
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the RingProgressBar widget"""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.RingProgressBar)
# widget: client.RingProgressBar
#
# widget.set_number_of_bars(3)
# widget.rings[0].set_update("manual")
# widget.rings[0].set_value(30)
# widget.rings[0].set_min_max_values(0, 100)
# widget.rings[1].set_update("scan")
# widget.rings[2].set_update("device", device="samx")
#
# # Test rpc calls
# dev = bec.device_manager.devices
# scans = bec.scans
# # Do a scan
# scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False).wait()
#
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)

View File

@@ -10,14 +10,17 @@ from qtpy.QtCore import QSettings, Qt, QTimer
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import QDialog, QMessageBox, QWidget
import bec_widgets.widgets.containers.dock_area.basic_dock_area as basic_dock_module
import bec_widgets.widgets.containers.dock_area.profile_utils as profile_utils
from bec_widgets.widgets.containers.dock_area.basic_dock_area import (
import bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area as basic_dock_module
import bec_widgets.widgets.containers.advanced_dock_area.profile_utils as profile_utils
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import (
AdvancedDockArea,
SaveProfileDialog,
)
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import (
DockAreaWidget,
DockSettingsDialog,
)
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea, SaveProfileDialog
from bec_widgets.widgets.containers.dock_area.profile_utils import (
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
SETTINGS_KEYS,
default_profile_path,
get_profile_info,
@@ -28,17 +31,20 @@ from bec_widgets.widgets.containers.dock_area.profile_utils import (
load_user_profile_screenshot,
open_default_settings,
open_user_settings,
plugin_profiles_dir,
read_manifest,
restore_user_from_default,
set_quick_select,
user_profile_path,
write_manifest,
)
from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import (
PreviewPanel,
RestoreProfileDialog,
)
from bec_widgets.widgets.containers.dock_area.settings.workspace_manager import WorkSpaceManager
from bec_widgets.widgets.containers.advanced_dock_area.settings.workspace_manager import (
WorkSpaceManager,
)
from .client_mocks import mocked_client
@@ -46,7 +52,7 @@ from .client_mocks import mocked_client
@pytest.fixture
def advanced_dock_area(qtbot, mocked_client):
"""Create an AdvancedDockArea instance for testing."""
widget = BECDockArea(client=mocked_client)
widget = AdvancedDockArea(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@@ -146,7 +152,7 @@ def workspace_manager_target():
"""Mock delete_profile that performs actual file deletion."""
from qtpy.QtWidgets import QMessageBox
from bec_widgets.widgets.containers.dock_area.profile_utils import (
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
delete_profile_files,
is_profile_read_only,
)
@@ -184,7 +190,7 @@ def basic_dock_area(qtbot, mocked_client):
class _NamespaceProfiles:
"""Helper that routes profile file helpers through a namespace."""
def __init__(self, widget: BECDockArea):
def __init__(self, widget: AdvancedDockArea):
self.namespace = widget.profile_namespace
def open_user(self, name: str):
@@ -209,7 +215,7 @@ class _NamespaceProfiles:
return is_quick_select(name, namespace=self.namespace)
def profile_helper(widget: BECDockArea) -> _NamespaceProfiles:
def profile_helper(widget: AdvancedDockArea) -> _NamespaceProfiles:
"""Return a helper wired to the widget's profile namespace."""
return _NamespaceProfiles(widget)
@@ -584,7 +590,7 @@ class TestAdvancedDockAreaInit:
def test_init(self, advanced_dock_area):
assert advanced_dock_area is not None
assert isinstance(advanced_dock_area, BECDockArea)
assert isinstance(advanced_dock_area, AdvancedDockArea)
assert advanced_dock_area.mode == "creator"
assert hasattr(advanced_dock_area, "dock_manager")
assert hasattr(advanced_dock_area, "toolbar")
@@ -592,8 +598,8 @@ class TestAdvancedDockAreaInit:
assert hasattr(advanced_dock_area, "state_manager")
def test_rpc_and_plugin_flags(self):
assert BECDockArea.RPC is True
assert BECDockArea.PLUGIN is False
assert AdvancedDockArea.RPC is True
assert AdvancedDockArea.PLUGIN is False
def test_user_access_list(self):
expected_methods = [
@@ -605,7 +611,7 @@ class TestAdvancedDockAreaInit:
"delete_all",
]
for method in expected_methods:
assert method in BECDockArea.USER_ACCESS
assert method in AdvancedDockArea.USER_ACCESS
class TestDockManagement:
@@ -1415,21 +1421,21 @@ class TestAdvancedDockAreaRestoreAndDialogs:
pix = QPixmap(8, 8)
pix.fill(Qt.red)
monkeypatch.setattr(
"bec_widgets.widgets.containers.dock_area.dock_area.load_user_profile_screenshot",
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot",
lambda name, namespace=None: pix,
)
monkeypatch.setattr(
"bec_widgets.widgets.containers.dock_area.dock_area.load_default_profile_screenshot",
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot",
lambda name, namespace=None: pix,
)
monkeypatch.setattr(
"bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm",
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm",
lambda *args, **kwargs: True,
)
with (
patch(
"bec_widgets.widgets.containers.dock_area.dock_area.restore_user_from_default"
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.restore_user_from_default"
) as mock_restore,
patch.object(advanced_dock_area, "delete_all") as mock_delete_all,
patch.object(advanced_dock_area, "load_profile") as mock_load_profile,
@@ -1451,20 +1457,20 @@ class TestAdvancedDockAreaRestoreAndDialogs:
advanced_dock_area._current_profile_name = profile_name
advanced_dock_area.isVisible = lambda: False
monkeypatch.setattr(
"bec_widgets.widgets.containers.dock_area.dock_area.load_user_profile_screenshot",
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot",
lambda name: QPixmap(),
)
monkeypatch.setattr(
"bec_widgets.widgets.containers.dock_area.dock_area.load_default_profile_screenshot",
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot",
lambda name: QPixmap(),
)
monkeypatch.setattr(
"bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm",
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm",
lambda *args, **kwargs: False,
)
with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.restore_user_from_default"
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.restore_user_from_default"
) as mock_restore:
advanced_dock_area.restore_user_profile_from_default()
@@ -1473,7 +1479,7 @@ class TestAdvancedDockAreaRestoreAndDialogs:
def test_restore_user_profile_from_default_no_target(self, advanced_dock_area, monkeypatch):
advanced_dock_area._current_profile_name = None
with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm"
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm"
) as mock_confirm:
advanced_dock_area.restore_user_profile_from_default()
mock_confirm.assert_not_called()
@@ -1717,7 +1723,8 @@ class TestWorkspaceProfileOperations:
return False
with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.SaveProfileDialog", StubDialog
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog",
StubDialog,
):
advanced_dock_area.save_profile(profile_name, show_dialog=True)
@@ -1788,7 +1795,8 @@ class TestWorkspaceProfileOperations:
return False
with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.SaveProfileDialog", StubDialog
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog",
StubDialog,
):
advanced_dock_area.save_profile(show_dialog=True)
@@ -1851,11 +1859,11 @@ class TestWorkspaceProfileOperations:
with (
patch(
"bec_widgets.widgets.containers.dock_area.dock_area.QMessageBox.question",
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question",
return_value=QMessageBox.Yes,
) as mock_question,
patch(
"bec_widgets.widgets.containers.dock_area.dock_area.QMessageBox.information",
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.information",
return_value=None,
) as mock_info,
):
@@ -1885,7 +1893,7 @@ class TestWorkspaceProfileOperations:
mock_get_action.return_value.widget = mock_combo
with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.QMessageBox.question"
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question"
) as mock_question:
mock_question.return_value = QMessageBox.Yes

View File

@@ -3,10 +3,9 @@ import time
import pytest
from qtpy.QtCore import QObject
from qtpy.QtWidgets import QApplication, QWidget
from qtpy.QtWidgets import QApplication
from bec_widgets.utils import BECConnector
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.error_popups import SafeSlot as Slot
from .client_mocks import mocked_client
@@ -132,33 +131,3 @@ def test_bec_connector_change_object_name(bec_connector):
# Verify that the object with the previous name is no longer registered
all_objects = bec_connector.rpc_register.list_all_connections().values()
assert not any(obj.objectName() == previous_name for obj in all_objects)
def test_bec_connector_export_settings():
class MyWidget(BECConnector, QWidget):
def __init__(self, parent=None, client=None, **kwargs):
super().__init__(parent=parent, client=client, **kwargs)
self.setWindowTitle("My Widget")
self._my_str_property = "default"
@SafeProperty(str)
def my_str_property(self) -> str:
return self._my_str_property
@my_str_property.setter
def my_str_property(self, value: str):
self._my_str_property = value
@property
def my_int_property(self) -> int:
return 42
widget = MyWidget(client=mocked_client)
out = widget.export_settings()
assert len(out) == 1
assert out["my_str_property"] == "default"
config = {"my_str_property": "new_value"}
widget.load_settings(config)
assert widget.my_str_property == "new_value"

View File

@@ -1,18 +1,28 @@
import numpy as np
import pytest
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
from bec_widgets import BECWidget
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
from .client_mocks import mocked_client
class _TestBusyWidget(BECWidget, QWidget):
def __init__(
self, parent=None, *, start_busy: bool = False, theme_update: bool = False, **kwargs
self,
parent=None,
*,
start_busy: bool = False,
busy_text: str = "Loading…",
theme_update: bool = False,
**kwargs,
):
super().__init__(parent=parent, theme_update=theme_update, start_busy=start_busy, **kwargs)
super().__init__(
parent=parent,
theme_update=theme_update,
start_busy=start_busy,
busy_text=busy_text,
**kwargs,
)
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(QLabel("content", self))
@@ -20,7 +30,7 @@ class _TestBusyWidget(BECWidget, QWidget):
@pytest.fixture
def widget_busy(qtbot, mocked_client):
w = _TestBusyWidget(client=mocked_client, start_busy=True)
w = _TestBusyWidget(client=mocked_client, start_busy=True, busy_text="Initializing…")
qtbot.addWidget(w)
w.resize(320, 200)
w.show()
@@ -47,46 +57,19 @@ def test_becwidget_start_busy_shows_overlay(qtbot, widget_busy):
def test_becwidget_set_busy_toggle_and_text(qtbot, widget_idle):
overlay = getattr(widget_idle, "_busy_overlay", None)
assert overlay is not None
assert overlay is None, "Overlay should be lazily created when idle"
widget_idle.set_busy(True)
widget_idle.set_busy(True, "Fetching data…")
overlay = getattr(widget_idle, "_busy_overlay")
qtbot.waitUntil(lambda: overlay.isVisible())
assert hasattr(widget_idle, "_busy_state_widget")
assert overlay._custom_widget is not None
label = overlay._custom_widget.findChild(QLabel)
assert label is not None
assert label.text() == "Loading..."
spinner = overlay._custom_widget.findChild(SpinnerWidget)
assert spinner is not None
assert spinner.isVisible()
assert spinner._started is True
lbl = getattr(overlay, "_label")
assert lbl.text() == "Fetching data…"
widget_idle.set_busy(False)
qtbot.waitUntil(lambda: overlay.isHidden())
def test_becwidget_busy_overlay_set_opacity(qtbot, widget_busy):
overlay = getattr(widget_busy, "_busy_overlay")
qtbot.waitUntil(lambda: overlay.isVisible())
# Default opacity is 0.7
frame = getattr(overlay, "_frame", None)
assert frame is not None
sheet = frame.styleSheet()
_, _, _, a = overlay.scrim_color.getRgb()
assert np.isclose(a / 255, 0.35, atol=0.02)
# Change opacity
overlay.set_opacity(0.7)
qtbot.waitUntil(lambda: overlay.isVisible())
_, _, _, a = overlay.scrim_color.getRgb()
assert np.isclose(a / 255, 0.7, atol=0.02)
def test_becwidget_overlay_tracks_resize(qtbot, widget_busy):
overlay = getattr(widget_busy, "_busy_overlay")
qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect())
@@ -120,6 +103,20 @@ def test_becwidget_overlay_frame_geometry_and_style(qtbot, widget_busy):
ss = frame.styleSheet()
assert "dashed" in ss
assert "border" in ss
assert "rgba(128, 128, 128, 110)" in ss
def test_becwidget_apply_busy_text_without_toggle(qtbot, widget_idle):
overlay = getattr(widget_idle, "_busy_overlay", None)
assert overlay is None, "Overlay should be created on first text update"
widget_idle.set_busy_text("Preparing…")
overlay = getattr(widget_idle, "_busy_overlay")
assert overlay is not None
assert overlay.isHidden()
lbl = getattr(overlay, "_label")
assert lbl.text() == "Preparing…"
def test_becwidget_busy_cycle_start_on_off_on(qtbot, widget_busy):
@@ -134,11 +131,15 @@ def test_becwidget_busy_cycle_start_on_off_on(qtbot, widget_busy):
qtbot.waitUntil(lambda: overlay.isHidden())
# Switch ON again (with new text)
widget_busy.set_busy(True)
widget_busy.set_busy(True, "Back to work…")
qtbot.waitUntil(lambda: overlay.isVisible())
# Same overlay instance reused (no duplication)
assert getattr(widget_busy, "_busy_overlay") is overlay
# Label updated
lbl = getattr(overlay, "_label")
assert lbl.text() == "Back to work…"
# Geometry follows parent after re-show
qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect())

View File

@@ -34,9 +34,9 @@ class _TestDuplicatePlugin(RPCBase): ...
mock_client_module_duplicate = SimpleNamespace()
_TestDuplicatePlugin.__name__ = "Waveform"
_TestDuplicatePlugin.__name__ = "DeviceComboBox"
mock_client_module_duplicate.Waveform = _TestDuplicatePlugin
mock_client_module_duplicate.DeviceComboBox = _TestDuplicatePlugin
@patch("bec_lib.logger.bec_logger")
@@ -47,14 +47,14 @@ mock_client_module_duplicate.Waveform = _TestDuplicatePlugin
@patch(
"bec_widgets.utils.bec_plugin_helper.get_all_plugin_widgets",
return_value=BECClassContainer(
[BECClassInfo(name="Waveform", obj=_TestDuplicatePlugin, module="", file="")]
[BECClassInfo(name="DeviceComboBox", obj=_TestDuplicatePlugin, module="", file="")]
),
)
def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock):
reload(client)
assert (
call(
f"Detected duplicate widget Waveform in plugin repo file: {inspect.getfile(_TestDuplicatePlugin)} !"
f"Detected duplicate widget DeviceComboBox in plugin repo file: {inspect.getfile(_TestDuplicatePlugin)} !"
)
in bec_logger.logger.warning.mock_calls
)

View File

@@ -3,13 +3,13 @@ from unittest import mock
import pytest
from bec_widgets.cli.client import BECDockArea
from bec_widgets.cli.client import AdvancedDockArea
from bec_widgets.cli.client_utils import BECGuiClient, _start_plot_process
@pytest.fixture
def cli_dock_area():
dock_area = BECDockArea(gui_id="test")
dock_area = AdvancedDockArea(gui_id="test")
with mock.patch.object(dock_area, "_run_rpc") as mock_rpc_call:
with mock.patch.object(dock_area, "_gui_is_alive", return_value=True):
yield dock_area, mock_rpc_call

View File

@@ -82,45 +82,6 @@ def test_rgba_to_hex():
assert Colors.rgba_to_hex(255, 87, 51) == "#FF5733FF"
def test_canonical_colormap_name_case_insensitive():
available = Colors.list_available_colormaps()
presets = Colors.list_available_gradient_presets()
if not available and not presets:
pytest.skip("No colormaps or presets available to test canonical mapping.")
name = (available or presets)[0]
requested = name.swapcase()
assert Colors.canonical_colormap_name(requested) == name
def test_validate_color_map_returns_canonical_name():
available = Colors.list_available_colormaps()
presets = Colors.list_available_gradient_presets()
if not available and not presets:
pytest.skip("No colormaps or presets available to test validation.")
name = (available or presets)[0]
requested = name.swapcase()
assert Colors.validate_color_map(requested) == name
def test_get_colormap_uses_gradient_preset_fallback(monkeypatch):
presets = Colors.list_available_gradient_presets()
if not presets:
pytest.skip("No gradient presets available to test fallback.")
preset = presets[0]
Colors._get_colormap_cached.cache_clear()
def _raise(*args, **kwargs):
raise Exception("registry unavailable")
monkeypatch.setattr(pg.colormap, "get", _raise)
cmap = Colors._get_colormap_cached(preset)
assert isinstance(cmap, pg.ColorMap)
@pytest.mark.parametrize("num", [10, 100, 400])
def test_evenly_spaced_colors(num):
colors_qcolor = Colors.evenly_spaced_colors(colormap="magma", num=num, format="QColor")

View File

@@ -59,13 +59,3 @@ def test_remove_readd_with_device_config(qtbot):
call(action="add", config=ANY, wait_for_response=False),
]
)
def test_cancel_config_action(qtbot):
ch = MagicMock(spec=ConfigHelper)
ch.send_config_request.return_value = "abcde"
cca = CommunicateConfigAction(config_helper=ch, device=None, config=None, action="cancel")
cca.run()
ch.send_config_request.assert_called_once_with(
action="cancel", config=None, wait_for_response=True, timeout_s=10
)

View File

@@ -1,68 +0,0 @@
# pylint skip-file
import pytest
from bec_lib.messages import DeviceInitializationProgressMessage
from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import (
DeviceInitializationProgressBar,
)
from .client_mocks import mocked_client
@pytest.fixture
def progress_bar(qtbot, mocked_client):
widget = DeviceInitializationProgressBar(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_progress_bar_initialization(progress_bar):
"""Test the initial state of the DeviceInitializationProgressBar."""
assert progress_bar.failed_devices == []
assert progress_bar.progress_bar._user_value == 0
assert progress_bar.progress_bar._user_maximum == 100
assert progress_bar.toolTip() == "No device initialization failures."
assert progress_bar.progress_label.text() == "Initializing devices..."
assert progress_bar.group_box.title() == "Config Update Progress"
def test_update_device_initialization_progress(progress_bar, qtbot):
"""Test updating the progress bar with different device initialization messages."""
# I. Update with message of running DeviceInitializationProgressMessage, finished=False, success=False
msg = DeviceInitializationProgressMessage(
device="DeviceA", index=1, total=3, finished=False, success=False
)
progress_bar._update_device_initialization_progress(msg.model_dump(), {})
assert progress_bar.progress_bar._user_value == 1
assert progress_bar.progress_bar._user_maximum == 3
assert progress_bar.progress_label.text() == f"{msg.device} initialization in progress..."
assert "1 / 3 - 33 %" == progress_bar.progress_bar.center_label.text()
# II. Update with message of finished DeviceInitializationProgressMessage, finished=True, success=True
msg.finished = True
msg.success = True
progress_bar._update_device_initialization_progress(msg.model_dump(), {})
assert progress_bar.progress_bar._user_value == 1
assert progress_bar.progress_bar._user_maximum == 3
assert progress_bar.progress_label.text() == f"{msg.device} initialization succeeded!"
assert "1 / 3 - 33 %" == progress_bar.progress_bar.center_label.text()
# III. Update with message of finished DeviceInitializationProgressMessage, finished=True, success=False
msg.finished = True
msg.success = False
msg.device = "DeviceB"
msg.index = 2
with qtbot.waitSignal(progress_bar.failed_devices_changed) as signal_blocker:
progress_bar._update_device_initialization_progress(msg.model_dump(), {})
assert progress_bar.progress_label.text() == f"{msg.device} initialization failed!"
assert "2 / 3 - 66 %" == progress_bar.progress_bar.center_label.text()
assert progress_bar.progress_bar._user_value == 2
assert progress_bar.progress_bar._user_maximum == 3
assert signal_blocker.args == [[msg.device]]
assert progress_bar.toolTip() == f"Failed devices: {msg.device}"

View File

@@ -9,7 +9,6 @@ from bec_widgets.widgets.control.device_input.base_classes.device_input_base imp
DeviceInputBase,
DeviceInputConfig,
)
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from .client_mocks import mocked_client
from .conftest import create_widget
@@ -143,24 +142,3 @@ def test_device_input_base_properties(device_input_base):
ReadoutPriority.MONITORED,
ReadoutPriority.ON_REQUEST,
]
def test_device_combobox_signal_class_filter(qtbot, mocked_client):
"""Test device filtering via signal_class_filter on combobox."""
mocked_client.device_manager.get_bec_signals = mock.MagicMock(
return_value=[
("samx", "async_signal", {"signal_class": "AsyncSignal"}),
("samy", "async_signal", {"signal_class": "AsyncSignal"}),
("bpm4i", "async_signal", {"signal_class": "AsyncSignal"}),
]
)
widget = create_widget(
qtbot=qtbot,
widget=DeviceComboBox,
client=mocked_client,
signal_class_filter=["AsyncSignal"],
)
devices = [widget.itemText(i) for i in range(widget.count())]
assert set(devices) == {"samx", "samy", "bpm4i"}
assert widget.signal_class_filter == ["AsyncSignal"]

View File

@@ -361,75 +361,6 @@ class TestDeviceTable:
assert device_table.search_input is not None
assert device_table.fuzzy_is_disabled.isChecked() is False
assert device_table.table.selectionBehavior() == QtWidgets.QAbstractItemView.SelectRows
assert hasattr(device_table, "client_callback_id")
def test_device_table_client_device_update_callback(
self, device_table: DeviceTable, mocked_client, qtbot
):
"""
Test that runs the client device update callback. This should update the status of devices in the table
that are in sync with the client.
I. First test will run a callback when no devices are in the table, should do nothing.
II. Second test will add devices all devices from the mocked_client, then remove one
device from the client and run the callback. The table should update the status of the
removed device to CAN_CONNECT and all others to CONNECTED.
"""
device_configs_changed_calls = []
requested_update_for_multiple_device_validations = []
def _device_configs_changed_cb(cfgs: list[dict], added: bool, skip_validation: bool):
"""Callback to capture device config changes."""
device_configs_changed_calls.append((cfgs, added, skip_validation))
def _requested_update_for_multiple_device_validations_cb(device_names: list):
"""Callback to capture requests for multiple device validations."""
requested_update_for_multiple_device_validations.append(device_names)
device_table.device_configs_changed.connect(_device_configs_changed_cb)
device_table.request_update_multiple_device_validations.connect(
_requested_update_for_multiple_device_validations_cb
)
# I. First test case with no devices in the table
with qtbot.waitSignal(device_table.request_update_after_client_device_update) as blocker:
device_table.request_update_after_client_device_update.emit()
assert blocker.signal_triggered is True
# Table should remain empty, and no updates should have occurred
assert not device_configs_changed_calls
assert not requested_update_for_multiple_device_validations
# II. Second test case, add all devices from mocked client to table
# Add all devices from mocked client to table.
device_configs = mocked_client.device_manager._get_redis_device_config()
device_table.add_device_configs(device_configs, skip_validation=True)
mocked_client.device_manager.devices.pop("samx") # Remove samx from client
with qtbot.waitSignal(device_table.request_update_after_client_device_update) as blocker:
validation_results = {
cfg.get("name"): (
DeviceModel.model_validate(cfg).model_dump(),
ConfigStatus.VALID,
ConnectionStatus.CONNECTED,
)
for cfg in device_configs
}
with mock.patch.object(
device_table, "get_validation_results", return_value=validation_results
):
device_table.request_update_after_client_device_update.emit()
assert blocker.signal_triggered is True
# Table should remain empty, and no updates should have occurred
# One for add_device_configs, one for the update
assert len(device_configs_changed_calls) == 2
# The first call should have one more device than the second
assert (
len(device_configs_changed_calls[0][0])
- len(device_configs_changed_calls[1][0])
== 1
)
# Only one device should have been marked for validation update
assert len(requested_update_for_multiple_device_validations) == 1
assert len(requested_update_for_multiple_device_validations[0]) == 1
def test_add_row(self, device_table: DeviceTable, sample_devices: dict):
"""Test adding a single device row."""
@@ -770,7 +701,7 @@ class TestOphydValidation:
assert isinstance(validation_button.icon(), QtGui.QIcon)
assert validation_button.styleSheet() == ""
validation_button.setEnabled(False)
assert validation_button.styleSheet() == ""
assert validation_button.styleSheet() == validation_button.transparent_style
@pytest.fixture
def validation_dialog(self, qtbot):
@@ -1129,7 +1060,9 @@ class TestOphydValidation:
ophyd_test, "_is_device_in_redis_session", return_value=True
) as mock_is_device_in_redis_session,
mock.patch.object(ophyd_test, "_add_device_config") as mock_add_device_config,
mock.patch.object(ophyd_test.list_widget, "get_widget") as mock_get_widget,
mock.patch.object(
ophyd_test, "_on_device_test_completed"
) as mock_on_device_test_completed,
):
ophyd_test.change_device_configs(
[{"name": "device_2", "deviceClass": "TestClass"}],
@@ -1137,7 +1070,12 @@ class TestOphydValidation:
skip_validation=False,
)
mock_add_device_config.assert_called_once()
mock_get_widget.assert_called_once_with("device_2")
mock_on_device_test_completed.assert_called_once_with(
{"name": "device_2", "deviceClass": "TestClass"},
ConfigStatus.VALID.value,
ConnectionStatus.CONNECTED.value,
"Device already in session.",
)
def test_ophyd_test_adding_devices(self, ophyd_test: OphydValidation, qtbot):
"""Test adding devices to OphydValidation widget."""

View File

@@ -23,14 +23,12 @@ from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.u
ValidationSection,
)
from bec_widgets.applications.views.device_manager_view.device_manager_display_widget import (
CustomBusyWidget,
DeviceManagerDisplayWidget,
)
from bec_widgets.applications.views.device_manager_view.device_manager_view import (
DeviceManagerView,
DeviceManagerWidget,
)
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.control.device_manager.components import (
DeviceTable,
DMConfigView,
@@ -58,9 +56,9 @@ class TestDeviceManagerViewDialogs:
"""Test class for DeviceManagerView dialog interactions."""
@pytest.fixture
def mock_dm_view(self, qtbot, mocked_client):
def mock_dm_view(self, qtbot):
"""Fixture for DeviceManagerView."""
widget = DeviceManagerDisplayWidget(client=mocked_client)
widget = DeviceManagerDisplayWidget()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@@ -307,7 +305,7 @@ class TestDeviceManagerViewDialogs:
qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton)
mock_warning_box.assert_called_once_with(
"Invalid Device Name",
f"Device is invalid, cannot be empty or contain spaces. Please provide a valid name. {dialog._device_config_template.get_config_fields().get('name', '')!r}",
f"Device is invalid, can not be empty with spaces. Please provide a valid name. {dialog._device_config_template.get_config_fields().get('name', '')!r} ",
)
mock_create_dialog.assert_not_called()
mock_create_validation.assert_not_called()
@@ -594,14 +592,6 @@ class TestDeviceManagerView:
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def custom_busy(self, qtbot, mocked_client):
"""Fixture for the custom busy widget of the DeviceManagerDisplayWidget."""
widget = CustomBusyWidget(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def device_configs(self, device_config: dict):
"""Fixture for multiple device configurations."""
@@ -613,34 +603,6 @@ class TestDeviceManagerView:
cfg_iter.append(dev_config_copy)
return cfg_iter
def test_custom_busy_widget(self, custom_busy: CustomBusyWidget, qtbot):
"""Test the CustomBusyWidget functionality."""
# Check layout
assert custom_busy.progress is not None
assert custom_busy.spinner is not None
assert custom_busy.spinner._started is False
# Check background
color = get_accent_colors()
bg = color._colors["BG"]
sheet = custom_busy.styleSheet()
assert bg.name() in sheet
assert "border-radius: 12px" in sheet
# Show event should start spinner
custom_busy.showEvent(None)
assert custom_busy.spinner._started is True
with qtbot.waitSignal(custom_busy.cancel_requested) as sig_blocker:
qtbot.mouseClick(custom_busy.cancel_button, QtCore.Qt.LeftButton)
# Check that the signal was emitted
assert sig_blocker.signal_triggered is True
# Hide should
custom_busy.hideEvent(None)
assert custom_busy.spinner._started is False
def test_device_manager_view_add_remove_device(
self, device_manager_display_widget: DeviceManagerDisplayWidget, device_config
):
@@ -780,59 +742,34 @@ class TestDeviceManagerView:
].action.action.triggered.emit()
assert len(mock_change_configs.call_args[0][0]) == 1
def test_handle_cancel_config_upload_failed(
self, device_manager_display_widget: DeviceManagerDisplayWidget, qtbot
def test_update_validation_icons_after_upload(
self,
device_manager_display_widget: DeviceManagerDisplayWidget,
device_configs: list[dict[str, Any]],
):
"""Test handling cancel during config upload failure."""
"""Test that validation icons are updated after uploading to Redis."""
dm_view = device_manager_display_widget
validation_results = {
"Device_1": (
{"name": "Device_1"},
ConfigStatus.VALID.value,
ConnectionStatus.CANNOT_CONNECT.value,
),
"Device_2": (
{"name": "Device_2"},
ConfigStatus.INVALID.value,
ConnectionStatus.UNKNOWN.value,
),
}
with mock.patch.object(
dm_view.device_table_view, "get_validation_results", return_value=validation_results
):
with (
mock.patch.object(
dm_view.device_table_view, "update_multiple_device_validations"
) as mock_update,
mock.patch.object(
dm_view.ophyd_test_view, "change_device_configs"
) as mock_change_configs,
):
with qtbot.waitSignal(
dm_view.device_table_view.device_config_in_sync_with_redis
) as sig_blocker:
dm_view._handle_cancel_config_upload_failed(
exception=Exception("Test Exception")
)
assert sig_blocker.signal_triggered is True
mock_change_configs.assert_called_once_with(
[validation_results["Device_1"][0], validation_results["Device_2"][0]],
added=True,
skip_validation=False,
)
mock_update.assert_called_once_with(
[
(
validation_results["Device_1"][0],
validation_results["Device_1"][1],
ConnectionStatus.UNKNOWN.value,
"Upload Cancelled",
),
(
validation_results["Device_2"][0],
validation_results["Device_2"][1],
ConnectionStatus.UNKNOWN.value,
"Upload Cancelled",
),
]
)
# Add device configs to the table
dm_view.device_table_view.add_device_configs(device_configs)
# Update the device manager devices to match what's in the table
dm_view.client.device_manager.devices = {cfg["name"]: cfg for cfg in device_configs}
# Simulate callback
dm_view._update_validation_icons_after_upload()
# Get validation results from the table
validation_results = dm_view.device_table_view.get_validation_results()
# Check that all devices are connected and status is updated
for dev_name, (cfg, _, connection_status) in validation_results.items():
assert cfg in device_configs
assert connection_status == ConnectionStatus.CONNECTED.value
# Check that no devices are in ophyd_validation widget
# Those should be all cleared after upload
cfgs = dm_view.ophyd_test_view.get_device_configs()
assert len(cfgs) == 0
# Check that upload config button is disabled
action = dm_view.toolbar.components.get_action("update_config_redis")
assert action.action.isEnabled() is False

View File

@@ -210,193 +210,3 @@ def test_signal_combobox_get_signal_name_with_velocity(qtbot, device_signal_comb
signal_name = device_signal_combobox.get_signal_name()
assert signal_name == "samx_velocity"
def test_signal_combobox_get_signal_config(device_signal_combobox):
device_signal_combobox.include_normal_signals = True
device_signal_combobox.include_hinted_signals = True
device_signal_combobox.set_device("samx")
index = device_signal_combobox.currentIndex()
assert index != -1
expected_config = device_signal_combobox.itemData(index)
assert expected_config is not None
assert device_signal_combobox.get_signal_config() == expected_config
def test_signal_combobox_get_signal_config_disabled(qtbot, mocked_client):
combobox = create_widget(
qtbot=qtbot, widget=SignalComboBox, client=mocked_client, store_signal_config=False
)
combobox.include_normal_signals = True
combobox.include_hinted_signals = True
combobox.set_device("samx")
assert combobox.get_signal_config() is None
def test_signal_combobox_signal_class_filter_by_device(qtbot, mocked_client):
"""Test signal_class_filter restricts signals to the selected device."""
mocked_client.device_manager.get_bec_signals = mock.MagicMock(
return_value=[
("samx", "samx_readback_async", {"obj_name": "samx_readback_async"}),
("samy", "samy_readback_async", {"obj_name": "samy_readback_async"}),
("bpm4i", "bpm4i_value_async", {"obj_name": "bpm4i_value_async"}),
]
)
widget = create_widget(
qtbot=qtbot,
widget=SignalComboBox,
client=mocked_client,
signal_class_filter=["AsyncSignal"],
device="samx",
)
assert widget.signals == ["samx_readback_async"]
assert widget.signal_class_filter == ["AsyncSignal"]
widget.set_device("samy")
assert widget.signals == ["samy_readback_async"]
def test_signal_class_filter_setter_clears_to_kind_filters(qtbot, mocked_client):
"""Clearing signal_class_filter should rebuild list using Kind filters."""
mocked_client.device_manager.get_bec_signals = mock.MagicMock(
return_value=[("samx", "samx_readback_async", {"obj_name": "samx_readback_async"})]
)
widget = create_widget(
qtbot=qtbot,
widget=SignalComboBox,
client=mocked_client,
signal_class_filter=["AsyncSignal"],
device="samx",
)
assert widget.signals == ["samx_readback_async"]
widget.signal_class_filter = []
samx = widget.dev.samx
assert widget.signals == [
("samx (readback)", samx._info["signals"].get("readback")),
("setpoint", samx._info["signals"].get("setpoint")),
("velocity", samx._info["signals"].get("velocity")),
]
def test_signal_class_filter_setter_none_reverts_to_kind_filters(qtbot, mocked_client):
"""Setting signal_class_filter to None should revert to Kind-based filtering."""
mocked_client.device_manager.get_bec_signals = mock.MagicMock(
return_value=[("samx", "samx_readback_async", {"obj_name": "samx_readback_async"})]
)
widget = create_widget(
qtbot=qtbot,
widget=SignalComboBox,
client=mocked_client,
signal_class_filter=["AsyncSignal"],
device="samx",
)
assert widget.signals == ["samx_readback_async"]
widget.signal_class_filter = None
samx = widget.dev.samx
assert widget.signals == [
("samx (readback)", samx._info["signals"].get("readback")),
("setpoint", samx._info["signals"].get("setpoint")),
("velocity", samx._info["signals"].get("velocity")),
]
def test_signal_combobox_set_first_element_as_empty(qtbot, mocked_client):
"""set_first_element_as_empty should insert/remove the empty option."""
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
widget.addItem("item1")
widget.addItem("item2")
widget.set_first_element_as_empty = True
assert widget.itemText(0) == ""
widget.set_first_element_as_empty = False
assert widget.itemText(0) == "item1"
def test_signal_combobox_class_kind_ndim_filters(qtbot, mocked_client):
"""Test class + kind + ndim filters are all applied together."""
mocked_client.device_manager.get_bec_signals = mock.MagicMock(
return_value=[
(
"samx",
"sig1",
{
"obj_name": "samx_sig1",
"kind_str": "hinted",
"describe": {"signal_info": {"ndim": 1}},
},
),
(
"samx",
"sig2",
{
"obj_name": "samx_sig2",
"kind_str": "config",
"describe": {"signal_info": {"ndim": 2}},
},
),
(
"samy",
"sig3",
{
"obj_name": "samy_sig3",
"kind_str": "normal",
"describe": {"signal_info": {"ndim": 1}},
},
),
]
)
widget = create_widget(
qtbot=qtbot,
widget=SignalComboBox,
client=mocked_client,
signal_class_filter=["AsyncSignal"],
ndim_filter=1,
device="samx",
)
# Default kinds are hinted + normal, ndim=1, device=samx
assert widget.signals == ["sig1"]
# Enable config kinds and widen ndim to include sig2
widget.include_config_signals = True
widget.ndim_filter = 2
assert widget.signals == ["sig2"]
def test_signal_combobox_require_device_validation(qtbot, mocked_client):
"""Require device should block validation and list updates without a device."""
mocked_client.device_manager.get_bec_signals = mock.MagicMock(
return_value=[
(
"samx",
"sig1",
{
"obj_name": "samx_sig1",
"kind_str": "hinted",
"describe": {"signal_info": {"ndim": 1}},
},
)
]
)
widget = create_widget(
qtbot=qtbot,
widget=SignalComboBox,
client=mocked_client,
signal_class_filter=["AsyncSignal"],
require_device=True,
)
assert widget.signals == []
widget.set_device("samx")
assert widget.signals == ["sig1"]
resets: list[str] = []
widget.signal_reset.connect(lambda: resets.append("reset"))
widget.check_validity("")
assert resets == ["reset"]

View File

@@ -45,30 +45,3 @@ def test_set_selection_line_edit(line_edit_mock):
FilterIO.set_selection(line_edit_mock, selection=["testC"])
assert FilterIO.check_input(widget=line_edit_mock, text="testA") is False
assert FilterIO.check_input(widget=line_edit_mock, text="testC") is True
def test_update_with_signal_class_combo_box_ndim_filter(dap_mock, mocked_client):
signals = [
("dev1", "sig1", {"describe": {"signal_info": {"ndim": 1}}}),
("dev1", "sig2", {"describe": {"signal_info": {"ndim": 2}}}),
]
mocked_client.device_manager.get_bec_signals = lambda _filters: signals
out = FilterIO.update_with_signal_class(
widget=dap_mock.fit_model_combobox,
signal_class_filter=["AsyncSignal"],
client=mocked_client,
ndim_filter=1,
)
assert out == [("dev1", "sig1", {"describe": {"signal_info": {"ndim": 1}}})]
def test_update_with_signal_class_line_edit_passthrough(line_edit_mock, mocked_client):
signals = [("dev1", "sig1", {"describe": {"signal_info": {"ndim": 1}}})]
mocked_client.device_manager.get_bec_signals = lambda _filters: signals
out = FilterIO.update_with_signal_class(
widget=line_edit_mock,
signal_class_filter=["AsyncSignal"],
client=mocked_client,
ndim_filter=1,
)
assert out == signals

View File

@@ -834,24 +834,6 @@ def test_device_properties_property_changed_signal(heatmap_widget):
mock_handler.assert_any_call("x_device_name", "samx")
def test_auto_emit_syncs_heatmap_toolbar_actions(heatmap_widget):
from unittest.mock import Mock
fft_action = heatmap_widget.toolbar.components.get_action("image_processing_fft").action
log_action = heatmap_widget.toolbar.components.get_action("image_processing_log").action
mock_handler = Mock()
heatmap_widget.property_changed.connect(mock_handler)
heatmap_widget.fft = True
heatmap_widget.log = True
assert fft_action.isChecked()
assert log_action.isChecked()
mock_handler.assert_any_call("fft", True)
mock_handler.assert_any_call("log", True)
def test_device_entry_validation_with_invalid_device(heatmap_widget):
"""Test that invalid device names are handled gracefully."""
# Try to set invalid device name

Some files were not shown because too many files have changed in this diff Show More