mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 01:37:53 +02:00
Compare commits
58 Commits
fix/image-
...
feature/be
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cbe2a4542 | |||
| d0d14ae78f | |||
| f850da49cd | |||
| f0d33c5cd7 | |||
| 48b333fd73 | |||
| 1534118f21 | |||
| 572797626c | |||
| 40a666aa18 | |||
| 577ca4301a | |||
| df4082b31b | |||
| aadb3e129a | |||
| 0580b539fa | |||
| b79c4862c5 | |||
| 148b41e238 | |||
| 6e398e8077 | |||
| 8d75c2af1c | |||
| 24dbb885f6 | |||
| 3b7bad85d3 | |||
| de09cc660a | |||
| 4bb8e86509 | |||
| e5b76bc855 | |||
| 99176198ee | |||
| dcfc573052 | |||
| 9290a9a23b | |||
| d48b9d224f | |||
| 43c311782d | |||
| 44f7acaeda | |||
| 0b212c3100 | |||
| d8ebae49ad | |||
| 153fb62a04 | |||
| d67227d20c | |||
| dc1072c247 | |||
| beb337201c | |||
| 75162ef8a8 | |||
| cc89252fb3 | |||
| 36fa0e649c | |||
| 8e173cb17e | |||
| 322655fc5e | |||
| 2b5b7360ae | |||
| b325d1bb4f | |||
| ee6fd5fb9e | |||
| 53fe1ac63d | |||
| 58e57169e8 | |||
| 2b27faf779 | |||
| b1a3403cd3 | |||
| b38d6dc549 | |||
| cc45fed387 | |||
| 5a594925f0 | |||
| e76dea6f69 | |||
| f4c14d66db | |||
| 4ef1344fec | |||
| 5e63814afe | |||
| 6be6dafd7d | |||
| fd1edf8177 | |||
| 8102f31956 | |||
| f9b92dacc3 | |||
| a219de11c1 | |||
| 45e9f03093 |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -17,6 +17,10 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
|
||||
@@ -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
|
||||
) -> AdvancedDockArea:
|
||||
) -> BECDockArea:
|
||||
"""
|
||||
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:
|
||||
AdvancedDockArea: The created advanced dock area.
|
||||
BECDockArea: 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 = AdvancedDockArea(
|
||||
widget = BECDockArea(
|
||||
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:
|
||||
AdvancedDockArea: The created dock area.
|
||||
BECDockArea: The created dock area.
|
||||
"""
|
||||
_auto_update = AutoUpdates(object_name=object_name)
|
||||
return _auto_update
|
||||
|
||||
@@ -27,14 +27,12 @@ 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
|
||||
|
||||
@@ -78,23 +76,28 @@ class LaunchTile(RoundedFrame):
|
||||
circular_pixmap.fill(Qt.transparent)
|
||||
|
||||
painter = QPainter(circular_pixmap)
|
||||
painter.setRenderHints(QPainter.Antialiasing, True)
|
||||
painter.setRenderHints(QPainter.RenderHint.Antialiasing, True)
|
||||
path = QPainterPath()
|
||||
path.addEllipse(0, 0, size, size)
|
||||
painter.setClipPath(path)
|
||||
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
pixmap = pixmap.scaled(
|
||||
size,
|
||||
size,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
painter.drawPixmap(0, 0, pixmap)
|
||||
painter.end()
|
||||
|
||||
self.icon_label.setPixmap(circular_pixmap)
|
||||
self.layout.addWidget(self.icon_label, alignment=Qt.AlignCenter)
|
||||
self.layout.addWidget(self.icon_label, alignment=Qt.AlignmentFlag.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.AlignCenter)
|
||||
self.layout.addWidget(self.top_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Main label
|
||||
self.main_label = QLabel(main_label)
|
||||
@@ -104,7 +107,7 @@ class LaunchTile(RoundedFrame):
|
||||
font_main.setPointSize(14)
|
||||
font_main.setBold(True)
|
||||
self.main_label.setFont(font_main)
|
||||
self.main_label.setAlignment(Qt.AlignCenter)
|
||||
self.main_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Shrink font if the default would wrap on this platform / DPI
|
||||
content_width = (
|
||||
@@ -120,13 +123,13 @@ class LaunchTile(RoundedFrame):
|
||||
|
||||
self.layout.addWidget(self.main_label)
|
||||
|
||||
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.layout.addItem(self.spacer_top)
|
||||
|
||||
# Description
|
||||
self.description_label = QLabel(description)
|
||||
self.description_label.setWordWrap(True)
|
||||
self.description_label.setAlignment(Qt.AlignCenter)
|
||||
self.description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.layout.addWidget(self.description_label)
|
||||
|
||||
# Selector
|
||||
@@ -136,7 +139,9 @@ class LaunchTile(RoundedFrame):
|
||||
else:
|
||||
self.selector = None
|
||||
|
||||
self.spacer_bottom = QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
self.spacer_bottom = QSpacerItem(
|
||||
0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
self.layout.addItem(self.spacer_bottom)
|
||||
|
||||
# Action button
|
||||
@@ -156,7 +161,7 @@ class LaunchTile(RoundedFrame):
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
|
||||
self.layout.addWidget(self.action_button, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
|
||||
"""
|
||||
@@ -179,12 +184,13 @@ class LaunchTile(RoundedFrame):
|
||||
metrics = QFontMetrics(font)
|
||||
label.setFont(font)
|
||||
label.setWordWrap(False)
|
||||
label.setText(metrics.elidedText(label.text(), Qt.ElideRight, max_width))
|
||||
label.setText(metrics.elidedText(label.text(), Qt.TextElideMode.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__(
|
||||
@@ -209,7 +215,7 @@ class LaunchWindow(BECMainWindow):
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
|
||||
self.spacer = QWidget(self)
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
self.toolbar.addWidget(self.dark_mode_button)
|
||||
|
||||
@@ -318,7 +324,7 @@ class LaunchWindow(BECMainWindow):
|
||||
)
|
||||
tile.setFixedWidth(self.TILE_SIZE[0])
|
||||
tile.setMinimumHeight(self.TILE_SIZE[1])
|
||||
tile.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding)
|
||||
tile.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.MinimumExpanding)
|
||||
if action_button:
|
||||
tile.action_button.clicked.connect(action_button)
|
||||
if show_selector and selector_items:
|
||||
@@ -428,7 +434,9 @@ class LaunchWindow(BECMainWindow):
|
||||
from bec_widgets.applications import bw_launch
|
||||
|
||||
with RPCRegister.delayed_broadcast() as rpc_register:
|
||||
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(AdvancedDockArea)
|
||||
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)
|
||||
if name is not None:
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
# If name already exists, generate a unique one with counter suffix
|
||||
@@ -451,13 +459,13 @@ class LaunchWindow(BECMainWindow):
|
||||
|
||||
if launch_script == "auto_update":
|
||||
auto_update = kwargs.pop("auto_update", None)
|
||||
return self._launch_auto_update(auto_update)
|
||||
return self._launch_auto_update(auto_update, geometry=geometry)
|
||||
|
||||
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)
|
||||
return self._launch_widget(widget, geometry=geometry)
|
||||
|
||||
launch = getattr(bw_launch, launch_script, None)
|
||||
if launch is None:
|
||||
@@ -469,13 +477,13 @@ class LaunchWindow(BECMainWindow):
|
||||
logger.info(f"Created new dock area: {name}")
|
||||
|
||||
if isinstance(result_widget, BECMainWindow):
|
||||
self._apply_window_geometry(result_widget, geometry)
|
||||
apply_window_geometry(result_widget, geometry)
|
||||
result_widget.show()
|
||||
else:
|
||||
window = BECMainWindowNoRPC()
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
|
||||
self._apply_window_geometry(window, geometry)
|
||||
apply_window_geometry(window, geometry)
|
||||
window.show()
|
||||
return result_widget
|
||||
|
||||
@@ -510,14 +518,15 @@ class LaunchWindow(BECMainWindow):
|
||||
window = BECMainWindow(object_name=filename)
|
||||
window.setCentralWidget(loaded)
|
||||
|
||||
QApplication.processEvents()
|
||||
window.setWindowTitle(f"BEC - {filename}")
|
||||
self._apply_window_geometry(window, None)
|
||||
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) -> AutoUpdates:
|
||||
def _launch_auto_update(
|
||||
self, auto_update: str, geometry: tuple[int, int, int, int] | None = None
|
||||
) -> AutoUpdates:
|
||||
if auto_update in self.available_auto_updates:
|
||||
auto_update_cls = self.available_auto_updates[auto_update]
|
||||
window = auto_update_cls()
|
||||
@@ -527,13 +536,14 @@ class LaunchWindow(BECMainWindow):
|
||||
window = AutoUpdates()
|
||||
|
||||
window.resize(window.minimumSizeHint())
|
||||
QApplication.processEvents()
|
||||
window.setWindowTitle(f"BEC - {window.objectName()}")
|
||||
self._apply_window_geometry(window, None)
|
||||
apply_window_geometry(window, geometry)
|
||||
window.show()
|
||||
return window
|
||||
|
||||
def _launch_widget(self, widget: type[BECWidget]) -> QWidget:
|
||||
def _launch_widget(
|
||||
self, widget: type[BECWidget], geometry: tuple[int, int, int, int] | None = None
|
||||
) -> QWidget:
|
||||
name = pascal_to_snake(widget.__name__)
|
||||
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
@@ -542,12 +552,11 @@ 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()}")
|
||||
self._apply_window_geometry(window, None)
|
||||
apply_window_geometry(window, geometry)
|
||||
window.show()
|
||||
return window
|
||||
|
||||
@@ -595,30 +604,9 @@ class LaunchWindow(BECMainWindow):
|
||||
raise ValueError(f"Widget {widget} not found in available widgets.")
|
||||
return self.launch("widget", widget=self.available_widgets[widget])
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def _open_custom_ui_file(self):
|
||||
@@ -665,10 +653,19 @@ class LaunchWindow(BECMainWindow):
|
||||
Check if the launcher is the last widget in the application.
|
||||
"""
|
||||
|
||||
remaining_connections = [
|
||||
connection for connection in connections.values() if connection.parent_id != self.gui_id
|
||||
]
|
||||
return len(remaining_connections) <= 4
|
||||
# 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
|
||||
|
||||
def _turn_off_the_lights(self, connections: dict):
|
||||
"""
|
||||
@@ -700,7 +697,7 @@ class LaunchWindow(BECMainWindow):
|
||||
self.hide()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
@@ -7,7 +7,12 @@ 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.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
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.main_window.main_window import BECMainWindow
|
||||
|
||||
|
||||
@@ -45,7 +50,7 @@ class BECMainApp(BECMainWindow):
|
||||
|
||||
def _add_views(self):
|
||||
self.add_section("BEC Applications", "bec_apps")
|
||||
self.ads = AdvancedDockArea(self, profile_namespace="bec", auto_profile_namespace=False)
|
||||
self.ads = BECDockArea(self, profile_namespace="bec", auto_profile_namespace=False)
|
||||
self.ads.setObjectName("MainWorkspace")
|
||||
self.device_manager = DeviceManagerView(self)
|
||||
self.developer_view = DeveloperView(self)
|
||||
@@ -211,25 +216,12 @@ def main(): # pragma: no cover
|
||||
apply_theme("dark")
|
||||
w = BECMainApp(show_examples=args.examples)
|
||||
|
||||
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)
|
||||
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())
|
||||
|
||||
w.show()
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget
|
||||
|
||||
@@ -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.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.dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
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 = AdvancedDockArea(
|
||||
self.plotting_ads = BECDockArea(
|
||||
self,
|
||||
mode="plot",
|
||||
default_add_direction="bottom",
|
||||
|
||||
@@ -56,8 +56,6 @@ 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():
|
||||
@@ -71,6 +69,9 @@ 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()
|
||||
@@ -175,12 +176,17 @@ class DeviceFormDialog(QtWidgets.QDialog):
|
||||
self.cancel_btn = QtWidgets.QPushButton("Cancel")
|
||||
self.reset_btn = QtWidgets.QPushButton("Reset Form")
|
||||
|
||||
btn_layout = QtWidgets.QHBoxLayout()
|
||||
for btn in (self.cancel_btn, self.reset_btn, self.test_connection_btn, self.add_btn):
|
||||
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.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
|
||||
btn_layout.addWidget(btn)
|
||||
btn_box = QtWidgets.QGroupBox("Actions")
|
||||
btn_box.setLayout(btn_layout)
|
||||
layout.addWidget(btn_box)
|
||||
|
||||
frame_layout.addWidget(btn_box)
|
||||
|
||||
# Connect signals to explicit slots
|
||||
@@ -285,7 +291,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)
|
||||
@@ -368,7 +374,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, can not be empty with spaces. Please provide a valid name. {config.get('name', '')!r} ",
|
||||
f"Device is invalid, cannot be empty or contain spaces. Please provide a valid name. {config.get('name', '')!r}",
|
||||
)
|
||||
msg_box.exec()
|
||||
return
|
||||
|
||||
@@ -513,7 +513,8 @@ class UploadRedisDialog(QtWidgets.QDialog):
|
||||
[
|
||||
detailed_text,
|
||||
"These devices may not be reachable and disabled BEC upon loading the config.",
|
||||
"Consider validating these connections before.",
|
||||
"Consider validating these connections before proceeding.\n\n",
|
||||
"Continue anyway?",
|
||||
]
|
||||
)
|
||||
reply = QtWidgets.QMessageBox.critical(
|
||||
|
||||
@@ -2,18 +2,29 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import partial
|
||||
from typing import List, Literal, get_args
|
||||
from typing import TYPE_CHECKING, 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
|
||||
from bec_lib.messages import ConfigAction, ScanStatusMessage
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtCore import QMetaObject, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QFileDialog, QMessageBox, QTextEdit, QVBoxLayout, QWidget
|
||||
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_widgets.applications.views.device_manager_view.device_manager_dialogs import (
|
||||
ConfigChoiceDialog,
|
||||
@@ -22,11 +33,12 @@ 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.advanced_dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.control.device_manager.components import (
|
||||
DeviceTable,
|
||||
DMConfigView,
|
||||
@@ -38,9 +50,16 @@ 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
|
||||
|
||||
@@ -51,6 +70,88 @@ _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."""
|
||||
|
||||
@@ -61,10 +162,20 @@ 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()
|
||||
|
||||
@@ -112,19 +223,58 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
),
|
||||
(
|
||||
self.device_table_view.device_config_in_sync_with_redis,
|
||||
(self._update_config_enabled_button,),
|
||||
(self._update_config_in_sync,),
|
||||
),
|
||||
(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)
|
||||
|
||||
@@ -306,6 +456,36 @@ 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):
|
||||
@@ -322,14 +502,6 @@ 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."""
|
||||
@@ -432,10 +604,8 @@ 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()
|
||||
@@ -511,12 +681,37 @@ 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(enabled=True, text="Uploading configuration to BEC Server...")
|
||||
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)
|
||||
|
||||
def _handle_push_complete_to_communicator(self):
|
||||
"""Handle completion of the config push to Redis."""
|
||||
self.set_busy(enabled=False)
|
||||
self._update_validation_icons_after_upload()
|
||||
self._set_busy_wrapper(enabled=False)
|
||||
|
||||
def _handle_exception_from_communicator(self, exception: Exception):
|
||||
"""Handle exceptions from the config communicator."""
|
||||
@@ -525,29 +720,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
"Error Uploading Config",
|
||||
f"An error occurred while uploading the configuration to BEC Server:\n{str(exception)}",
|
||||
)
|
||||
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)
|
||||
self._set_busy_wrapper(enabled=False)
|
||||
|
||||
@SafeSlot()
|
||||
def _save_to_disk_action(self):
|
||||
@@ -613,8 +786,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
):
|
||||
if old_device_name and old_device_name != data.get("name", ""):
|
||||
self.device_table_view.remove_device(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)
|
||||
self._add_to_table_from_dialog(data, config_status, connection_status, msg, old_device_name)
|
||||
|
||||
@SafeSlot(dict, int, int, str, str)
|
||||
def _add_to_table_from_dialog(
|
||||
@@ -625,8 +797,15 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
msg: str,
|
||||
old_device_name: str = "",
|
||||
):
|
||||
self.device_table_view.add_device_configs([data], skip_validation=True)
|
||||
self.device_table_view.update_device_validation(data, config_status, connection_status, msg)
|
||||
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
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _remove_device_action(self):
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from qtpy.QtCore import QEventLoop, Qt, QTimer
|
||||
from qtpy.QtCore import QEventLoop
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
@@ -11,54 +9,18 @@ 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.
|
||||
|
||||
@@ -69,6 +31,7 @@ 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__(
|
||||
@@ -78,6 +41,8 @@ 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
|
||||
@@ -88,15 +53,48 @@ 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
|
||||
self.layout().addWidget(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)
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
@@ -115,68 +113,6 @@ 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
|
||||
|
||||
@@ -35,8 +35,6 @@ _Widgets = {
|
||||
"DapComboBox": "DapComboBox",
|
||||
"DarkModeButton": "DarkModeButton",
|
||||
"DeviceBrowser": "DeviceBrowser",
|
||||
"DeviceComboBox": "DeviceComboBox",
|
||||
"DeviceLineEdit": "DeviceLineEdit",
|
||||
"Heatmap": "Heatmap",
|
||||
"Image": "Image",
|
||||
"LogPanel": "LogPanel",
|
||||
@@ -56,11 +54,8 @@ _Widgets = {
|
||||
"ScanControl": "ScanControl",
|
||||
"ScanProgressBar": "ScanProgressBar",
|
||||
"ScatterWaveform": "ScatterWaveform",
|
||||
"SignalComboBox": "SignalComboBox",
|
||||
"SignalLabel": "SignalLabel",
|
||||
"SignalLineEdit": "SignalLineEdit",
|
||||
"TextBox": "TextBox",
|
||||
"VSCodeEditor": "VSCodeEditor",
|
||||
"Waveform": "Waveform",
|
||||
"WebConsole": "WebConsole",
|
||||
"WebsiteWidget": "WebsiteWidget",
|
||||
@@ -95,7 +90,63 @@ except ImportError as e:
|
||||
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
|
||||
|
||||
|
||||
class AdvancedDockArea(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 BECDockArea(RPCBase):
|
||||
@rpc_call
|
||||
def new(
|
||||
self,
|
||||
@@ -325,62 +376,6 @@ class AdvancedDockArea(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):
|
||||
@@ -1048,26 +1043,25 @@ class DeviceBrowser(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class DeviceComboBox(RPCBase):
|
||||
"""Combobox widget for device input with autocomplete for device names."""
|
||||
class DeviceInitializationProgressBar(RPCBase):
|
||||
"""A progress bar that displays the progress of device initialization."""
|
||||
|
||||
@rpc_call
|
||||
def set_device(self, device: "str"):
|
||||
def remove(self):
|
||||
"""
|
||||
Set the device.
|
||||
|
||||
Args:
|
||||
device (str): Default name.
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def devices(self) -> "list[str]":
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
Get the list of devices for the applied filters.
|
||||
|
||||
Returns:
|
||||
list[str]: List of devices.
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
@@ -1093,39 +1087,6 @@ 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"""
|
||||
|
||||
@@ -2540,16 +2501,30 @@ class Image(RPCBase):
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def monitor(self) -> "str":
|
||||
def device_name(self) -> "str":
|
||||
"""
|
||||
The name of the monitor to use for the image.
|
||||
The name of the device to monitor for image data.
|
||||
"""
|
||||
|
||||
@monitor.setter
|
||||
@device_name.setter
|
||||
@rpc_call
|
||||
def monitor(self) -> "str":
|
||||
def device_name(self) -> "str":
|
||||
"""
|
||||
The name of the monitor to use for the image.
|
||||
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.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
@@ -2655,8 +2630,8 @@ class Image(RPCBase):
|
||||
@rpc_call
|
||||
def image(
|
||||
self,
|
||||
monitor: "str | tuple | None" = None,
|
||||
monitor_type: "Literal['auto', '1d', '2d']" = "auto",
|
||||
device_name: "str | None" = None,
|
||||
device_entry: "str | None" = None,
|
||||
color_map: "str | None" = None,
|
||||
color_bar: "Literal['simple', 'full'] | None" = None,
|
||||
vrange: "tuple[int, int] | None" = None,
|
||||
@@ -2665,14 +2640,14 @@ class Image(RPCBase):
|
||||
Set the image source and update the image.
|
||||
|
||||
Args:
|
||||
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".
|
||||
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.
|
||||
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.
|
||||
ImageItem: The image object, or None if connection failed.
|
||||
"""
|
||||
|
||||
@property
|
||||
@@ -4691,29 +4666,6 @@ 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"):
|
||||
"""
|
||||
@@ -4733,14 +4685,24 @@ class Ring(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_background(self, color: "str | tuple"):
|
||||
def set_background(self, color: "str | tuple | QColor"):
|
||||
"""
|
||||
Set the background color for the ring widget
|
||||
Set the background color for the ring widget. The background color is only used when colors are not linked.
|
||||
|
||||
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"):
|
||||
"""
|
||||
@@ -4763,14 +4725,16 @@ 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" = None):
|
||||
def set_update(
|
||||
self, mode: "Literal['manual', 'scan', 'device']", device: "str" = "", signal: "str" = ""
|
||||
):
|
||||
"""
|
||||
Set the update mode for the ring widget.
|
||||
Modes:
|
||||
@@ -4781,193 +4745,24 @@ 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 reset_connection(self):
|
||||
def set_precision(self, precision: "int"):
|
||||
"""
|
||||
Reset the connections for the ring widget. Disconnect the current slot and endpoint.
|
||||
Set the precision for the ring widget.
|
||||
|
||||
Args:
|
||||
precision(int): Precision for the ring widget
|
||||
"""
|
||||
|
||||
|
||||
class RingProgressBar(RPCBase):
|
||||
"""Show the progress of devices, scans or custom values in the form of ring progress bars."""
|
||||
|
||||
@rpc_call
|
||||
def _get_all_rpc(self) -> "dict":
|
||||
def remove(self):
|
||||
"""
|
||||
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.
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
@@ -4989,6 +4784,56 @@ 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."""
|
||||
@@ -5519,47 +5364,6 @@ 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
|
||||
@@ -5702,48 +5506,6 @@ 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"""
|
||||
|
||||
@@ -5766,12 +5528,6 @@ class TextBox(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class VSCodeEditor(RPCBase):
|
||||
"""A widget to display the VSCode editor."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class Waveform(RPCBase):
|
||||
"""Widget for plotting waveforms."""
|
||||
|
||||
|
||||
@@ -5,14 +5,13 @@ 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
|
||||
|
||||
@@ -109,11 +108,19 @@ class RPCRegister:
|
||||
dict: A dictionary containing all the registered RPC objects.
|
||||
"""
|
||||
with self._lock:
|
||||
connections = dict(self._rpc_register)
|
||||
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
|
||||
return connections
|
||||
|
||||
def get_names_of_rpc_by_class_type(
|
||||
self, cls: type[BECWidget] | type[BECConnector] | type[BECDock] | type[BECDockArea]
|
||||
self, cls: type[BECWidget] | type[BECConnector]
|
||||
) -> list[str]:
|
||||
"""Get all the names of the widgets.
|
||||
|
||||
|
||||
@@ -25,6 +25,16 @@ 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):
|
||||
|
||||
@@ -8,10 +8,11 @@ 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 QObject, QRunnable, QThreadPool, QTimer, Signal
|
||||
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, QTimer, Signal
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
@@ -185,7 +186,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
|
||||
|
||||
QTimer.singleShot(0, self._update_object_name)
|
||||
self._update_object_name()
|
||||
|
||||
@property
|
||||
def parent_id(self) -> str | None:
|
||||
@@ -206,7 +207,7 @@ class BECConnector:
|
||||
"""
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.setObjectName(name.replace("-", "_").replace(" ", "_"))
|
||||
QTimer.singleShot(0, self._update_object_name)
|
||||
self._update_object_name()
|
||||
|
||||
def _update_object_name(self) -> None:
|
||||
"""
|
||||
@@ -219,7 +220,8 @@ class BECConnector:
|
||||
self.rpc_register.add_rpc(self)
|
||||
try:
|
||||
self.name_established.emit(self.object_name)
|
||||
except RuntimeError:
|
||||
except RuntimeError as e:
|
||||
logger.warning(f"Error emitting name_established signal: {e}")
|
||||
return
|
||||
|
||||
def _enforce_unique_sibling_name(self):
|
||||
@@ -230,23 +232,20 @@ 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.
|
||||
"""
|
||||
QApplication.sendPostedEvents()
|
||||
if not shb.isValid(self):
|
||||
return
|
||||
|
||||
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
|
||||
if parent_bec:
|
||||
# We have a parent => only compare with siblings under that parent
|
||||
siblings = parent_bec.findChildren(BECConnector)
|
||||
siblings = [sib for sib in parent_bec.findChildren(BECConnector) if shb.isValid(sib)]
|
||||
else:
|
||||
# No parent => treat all top-level BECConnectors as siblings
|
||||
# 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
|
||||
# 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]
|
||||
|
||||
# Collect used names among siblings
|
||||
used_names = {sib.objectName() for sib in siblings if sib is not self}
|
||||
@@ -481,6 +480,62 @@ 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
|
||||
|
||||
@@ -6,17 +6,20 @@ 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 QPixmap
|
||||
from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
|
||||
from qtpy.QtGui import QFont, QPixmap
|
||||
from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, 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
|
||||
@@ -38,7 +41,6 @@ class BECWidget(BECConnector):
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = False,
|
||||
start_busy: bool = False,
|
||||
busy_text: str = "Loading…",
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@@ -65,18 +67,14 @@ class BECWidget(BECConnector):
|
||||
self._connect_to_theme_change()
|
||||
|
||||
# Initialize optional busy loader overlay utility (lazy by default)
|
||||
self._busy_overlay = None
|
||||
self._busy_overlay: "BusyLoaderOverlay" | None = None
|
||||
self._busy_state_widget: QWidget | None = None
|
||||
|
||||
self._loading = False
|
||||
self._busy_overlay = self._install_busy_loader()
|
||||
if start_busy and isinstance(self, QWidget):
|
||||
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}")
|
||||
self._show_busy_overlay()
|
||||
self._loading = True
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
@@ -97,48 +95,109 @@ class BECWidget(BECConnector):
|
||||
self._update_overlay_theme(theme)
|
||||
self.apply_theme(theme)
|
||||
|
||||
def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"):
|
||||
"""Create the busy overlay on demand and cache it in _busy_overlay.
|
||||
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.
|
||||
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(self, text=busy_text, start_loading=False)
|
||||
overlay = install_busy_loader(target=self, 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 _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> None:
|
||||
def _show_busy_overlay(self) -> None:
|
||||
"""Create and attach the loading overlay to this widget if QWidget is present."""
|
||||
if not isinstance(self, QWidget):
|
||||
return
|
||||
self._ensure_busy_overlay(busy_text=busy_text)
|
||||
if start_busy and self._busy_overlay is not None:
|
||||
self._busy_overlay.setGeometry(self.rect())
|
||||
if self._busy_overlay is not None:
|
||||
self._busy_overlay.setGeometry(self.rect()) # pylint: disable=no-member
|
||||
self._busy_overlay.raise_()
|
||||
self._busy_overlay.show()
|
||||
|
||||
def set_busy(self, enabled: bool, text: str | None = None) -> None:
|
||||
def set_busy(self, enabled: bool) -> None:
|
||||
"""
|
||||
Enable/disable the loading overlay. Optionally update the text.
|
||||
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.
|
||||
|
||||
Args:
|
||||
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.
|
||||
enabled(bool): Whether to enable the busy state.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
return
|
||||
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 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 enabled:
|
||||
self._busy_overlay.setGeometry(self.rect())
|
||||
self._busy_overlay.raise_()
|
||||
self._busy_overlay.show()
|
||||
self._show_busy_overlay()
|
||||
else:
|
||||
self._busy_overlay.hide()
|
||||
self._loading = bool(enabled)
|
||||
@@ -152,19 +211,6 @@ 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):
|
||||
"""
|
||||
@@ -177,8 +223,8 @@ class BECWidget(BECConnector):
|
||||
def _update_overlay_theme(self, theme: str):
|
||||
try:
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None and hasattr(overlay, "update_palette"):
|
||||
overlay.update_palette()
|
||||
if overlay is not None:
|
||||
overlay._update_palette()
|
||||
except Exception:
|
||||
logger.warning(f"Failed to apply theme {theme} to {self}")
|
||||
|
||||
@@ -304,10 +350,13 @@ 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."""
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QEvent, QObject, Qt, QTimer
|
||||
from qtpy.QtGui import QColor, QFont
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QEvent, QObject, Qt, QTimer, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
@@ -13,10 +14,10 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class _OverlayEventFilter(QObject):
|
||||
@@ -28,6 +29,10 @@ 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,
|
||||
@@ -53,132 +58,201 @@ class BusyLoaderOverlay(QWidget):
|
||||
BusyLoaderOverlay: The overlay instance.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **kwargs):
|
||||
foreground_color_changed = Signal(QColor)
|
||||
scrim_color_changed = Signal(QColor)
|
||||
|
||||
def __init__(self, parent: QWidget, opacity: float = 0.35, **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
|
||||
|
||||
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)
|
||||
# Set Main Layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
layout.setSpacing(10)
|
||||
self.setLayout(layout)
|
||||
|
||||
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)
|
||||
# Custom widget placeholder
|
||||
self._custom_widget: QWidget | None = None
|
||||
|
||||
# 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._scrim_color = QColor(0, 0, 0, 110)
|
||||
self._label_color = QColor(240, 240, 240)
|
||||
self.update_palette()
|
||||
self._update_palette()
|
||||
|
||||
# Start hidden; interactions beneath are blocked while visible
|
||||
self.hide()
|
||||
|
||||
# --- API ---
|
||||
def set_text(self, text: str):
|
||||
@SafeProperty(QColor, notify=scrim_color_changed)
|
||||
def scrim_color(self) -> QColor:
|
||||
"""
|
||||
Update the overlay text.
|
||||
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.
|
||||
|
||||
Args:
|
||||
text(str): The text to display on the overlay.
|
||||
filt(QObject): The event filter instance.
|
||||
"""
|
||||
self._label.setText(text)
|
||||
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)
|
||||
|
||||
def set_opacity(self, opacity: float):
|
||||
"""
|
||||
Set overlay opacity (0..1).
|
||||
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.
|
||||
|
||||
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
|
||||
if isinstance(self._scrim_color, QColor):
|
||||
base = QColor(self._scrim_color)
|
||||
base.setAlpha(int(255 * self._opacity))
|
||||
self._scrim_color = base
|
||||
self.update()
|
||||
base = self.scrim_color
|
||||
base.setAlpha(int(255 * self._opacity))
|
||||
self.scrim_color = base
|
||||
self._update_palette()
|
||||
|
||||
def update_palette(self):
|
||||
##########################
|
||||
### Internal methods ###
|
||||
##########################
|
||||
|
||||
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]
|
||||
self._bg = theme.color("BORDER")
|
||||
self._fg = theme.color("FG")
|
||||
self._primary = theme.color("PRIMARY")
|
||||
_app = QApplication.instance()
|
||||
if hasattr(_app, "theme"):
|
||||
theme = _app.theme # type: ignore[attr-defined]
|
||||
_bg = theme.color("BORDER")
|
||||
_fg = theme.color("FG")
|
||||
else:
|
||||
# Fallback neutrals
|
||||
self._bg = QColor(30, 30, 30)
|
||||
self._fg = QColor(230, 230, 230)
|
||||
_bg = QColor(30, 30, 30)
|
||||
_fg = QColor(230, 230, 230)
|
||||
|
||||
# Semi-transparent scrim derived from bg
|
||||
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};")
|
||||
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._frame.setStyleSheet(
|
||||
f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}"
|
||||
f"#busyFrame {{ border: 2px dashed {self.foreground_color.name()}; border-radius: 9px; background-color: rgba({r}, {g}, {b}, {a}); }}"
|
||||
)
|
||||
self.update()
|
||||
|
||||
# --- QWidget overrides ---
|
||||
#############################
|
||||
### Custom Event Handlers ###
|
||||
#############################
|
||||
|
||||
def showEvent(self, e):
|
||||
self._spinner.start()
|
||||
# Call showEvent on custom widget if present
|
||||
if self._custom_widget is not None:
|
||||
self._custom_widget.showEvent(e)
|
||||
super().showEvent(e)
|
||||
|
||||
def hideEvent(self, e):
|
||||
self._spinner.stop()
|
||||
# Call hideEvent on custom widget if present
|
||||
if self._custom_widget is not None:
|
||||
self._custom_widget.hideEvent(e)
|
||||
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)
|
||||
|
||||
def paintEvent(self, e):
|
||||
super().paintEvent(e)
|
||||
# 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 install_busy_loader(
|
||||
target: QWidget, text: str = "Loading…", start_loading: bool = False, opacity: float = 0.35
|
||||
target: QWidget, 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(target, text=text, opacity=opacity)
|
||||
overlay = BusyLoaderOverlay(parent=target, opacity=opacity)
|
||||
overlay.setGeometry(target.rect())
|
||||
filt = _OverlayEventFilter(target, overlay)
|
||||
overlay._filter = filt # type: ignore[attr-defined]
|
||||
target.installEventFilter(filt)
|
||||
overlay.set_filter(_OverlayEventFilter(target=target, overlay=overlay))
|
||||
if start_loading:
|
||||
overlay.show()
|
||||
return overlay
|
||||
@@ -187,65 +261,63 @@ 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()
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
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"):
|
||||
@@ -47,12 +52,103 @@ 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:
|
||||
@@ -134,7 +230,7 @@ class Colors:
|
||||
if theme_offset < 0 or theme_offset > 1:
|
||||
raise ValueError("theme_offset must be between 0 and 1")
|
||||
|
||||
cmap = pg.colormap.get(colormap)
|
||||
cmap = Colors.get_colormap(colormap)
|
||||
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
|
||||
|
||||
# Generate positions that are evenly spaced within the acceptable range
|
||||
@@ -182,7 +278,7 @@ class Colors:
|
||||
ValueError: If theme_offset is not between 0 and 1.
|
||||
"""
|
||||
|
||||
cmap = pg.colormap.get(colormap)
|
||||
cmap = Colors.get_colormap(colormap)
|
||||
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
|
||||
golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125
|
||||
|
||||
@@ -448,18 +544,103 @@ class Colors:
|
||||
Raises:
|
||||
PydanticCustomError: If colormap is invalid.
|
||||
"""
|
||||
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:
|
||||
normalized = Colors.canonical_colormap_name(color_map)
|
||||
try:
|
||||
Colors.get_colormap(normalized)
|
||||
except Exception as ext:
|
||||
logger.warning(f"Colormap validation error: {ext}")
|
||||
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 on the following: {available_colormaps}.",
|
||||
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose from the following: {available_colormaps}.",
|
||||
{"wrong_value": color_map},
|
||||
)
|
||||
else:
|
||||
return False
|
||||
return color_map
|
||||
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)
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
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, QMessageBox, QPushButton, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
RAISE_ERROR_DEFAULT = False
|
||||
|
||||
|
||||
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs):
|
||||
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,
|
||||
):
|
||||
"""
|
||||
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.
|
||||
@@ -22,7 +41,15 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
|
||||
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.
|
||||
*prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor.
|
||||
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).
|
||||
|
||||
Usage:
|
||||
@SafeProperty(int, default=-1)
|
||||
@@ -34,6 +61,41 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
|
||||
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):
|
||||
@@ -53,6 +115,8 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
|
||||
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(...).
|
||||
@@ -68,8 +132,42 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
|
||||
@functools.wraps(setter_func)
|
||||
def safe_setter(self_, value):
|
||||
try:
|
||||
return setter_func(self_, value)
|
||||
except Exception:
|
||||
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}")
|
||||
prop_name = f"{setter_func.__module__}.{setter_func.__qualname__}"
|
||||
error_msg = traceback.format_exc()
|
||||
|
||||
@@ -335,6 +433,100 @@ 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.
|
||||
@@ -389,6 +581,10 @@ class ExampleWidget(QWidget): # pragma: no cover
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = ExampleWidget()
|
||||
widget.show()
|
||||
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()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
@@ -55,6 +56,49 @@ 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"""
|
||||
@@ -255,6 +299,32 @@ 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):
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageChops
|
||||
from qtpy.QtGui import QPixmap
|
||||
@@ -40,7 +41,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
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import time
|
||||
import traceback
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
@@ -12,7 +11,6 @@ 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
|
||||
@@ -32,6 +30,10 @@ 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"""
|
||||
@@ -55,6 +57,19 @@ 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
|
||||
@@ -86,6 +101,7 @@ 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}")
|
||||
@@ -109,7 +125,8 @@ class RPCServer:
|
||||
self.send_response(request_id, False, {"error": content})
|
||||
else:
|
||||
logger.debug(f"RPC instruction executed successfully: {res}")
|
||||
self.send_response(request_id, True, {"result": res})
|
||||
self._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat()
|
||||
QTimer.singleShot(0, lambda: self.serialize_result_and_send(request_id, res))
|
||||
|
||||
def send_response(self, request_id: str, accepted: bool, msg: dict):
|
||||
self.client.connector.set_and_publish(
|
||||
@@ -167,14 +184,61 @@ 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)
|
||||
return 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)
|
||||
|
||||
def serialize_object(self, obj: T) -> None | dict | T:
|
||||
"""
|
||||
@@ -256,11 +320,8 @@ class RPCServer:
|
||||
except Exception:
|
||||
container_proxy = None
|
||||
|
||||
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)
|
||||
if wait and not self.rpc_register.object_is_registered(connector):
|
||||
raise RegistryNotReadyError(f"Connector {connector} not registered yet")
|
||||
|
||||
widget_class = getattr(connector, "rpc_widget_class", None)
|
||||
if not widget_class:
|
||||
|
||||
100
bec_widgets/utils/screen_utils.py
Normal file
100
bec_widgets/utils/screen_utils.py
Normal file
@@ -0,0 +1,100 @@
|
||||
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))
|
||||
@@ -5,7 +5,8 @@ import os
|
||||
import weakref
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict, Literal
|
||||
from enum import Enum
|
||||
from typing import Dict, Literal, Union
|
||||
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from bec_lib.logger import bec_logger
|
||||
@@ -15,6 +16,7 @@ from qtpy.QtGui import QAction, QColor, QIcon # type: ignore
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QGraphicsDropShadowEffect,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMenu,
|
||||
@@ -26,6 +28,8 @@ 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
|
||||
|
||||
@@ -101,6 +105,205 @@ 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.
|
||||
@@ -147,6 +350,54 @@ 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,
|
||||
@@ -498,6 +749,82 @@ 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.
|
||||
|
||||
@@ -7,10 +7,17 @@ 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, ToolBarAction
|
||||
from bec_widgets.utils.toolbars.actions import SeparatorAction, SplitterAction, ToolBarAction
|
||||
|
||||
DEFAULT_SIZE = 400
|
||||
MAX_SIZE = 10_000_000
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
|
||||
@@ -195,6 +202,84 @@ 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.
|
||||
|
||||
@@ -1,18 +1,136 @@
|
||||
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
|
||||
@@ -20,4 +138,6 @@ 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.
|
||||
"""
|
||||
|
||||
241
bec_widgets/utils/toolbars/splitter.py
Normal file
241
bec_widgets/utils/toolbars/splitter.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
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()
|
||||
283
bec_widgets/utils/toolbars/status_bar.py
Normal file
283
bec_widgets/utils/toolbars/status_bar.py
Normal file
@@ -0,0 +1,283 @@
|
||||
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)
|
||||
@@ -8,10 +8,19 @@ 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, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QToolBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme, get_theme_name
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction, WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
@@ -406,9 +415,18 @@ class ModularToolBar(QToolBar):
|
||||
|
||||
def update_separators(self):
|
||||
"""
|
||||
Hide separators that are adjacent to another separator or have no non-separator actions between them.
|
||||
Hide separators that are adjacent to another separator, splitters, or have no non-separator actions between them.
|
||||
Splitters (ResizableSpacer) already provide visual separation, so we don't need separators next to them.
|
||||
"""
|
||||
from bec_widgets.utils.toolbars.splitter import ResizableSpacer
|
||||
|
||||
toolbar_actions = self.actions()
|
||||
|
||||
# Helper function to check if a widget is a splitter
|
||||
def is_splitter_widget(action):
|
||||
widget = self.widgetForAction(action)
|
||||
return widget is not None and isinstance(widget, ResizableSpacer)
|
||||
|
||||
# First pass: set visibility based on surrounding non-separator actions.
|
||||
for i, action in enumerate(toolbar_actions):
|
||||
if not action.isSeparator():
|
||||
@@ -423,23 +441,32 @@ class ModularToolBar(QToolBar):
|
||||
if toolbar_actions[j].isVisible():
|
||||
next_visible = toolbar_actions[j]
|
||||
break
|
||||
if (prev_visible is None or prev_visible.isSeparator()) and (
|
||||
next_visible is None or next_visible.isSeparator()
|
||||
|
||||
# Hide separator if adjacent to another separator, splitter, or at edges
|
||||
if (
|
||||
prev_visible is None
|
||||
or prev_visible.isSeparator()
|
||||
or is_splitter_widget(prev_visible)
|
||||
) and (
|
||||
next_visible is None
|
||||
or next_visible.isSeparator()
|
||||
or is_splitter_widget(next_visible)
|
||||
):
|
||||
action.setVisible(False)
|
||||
else:
|
||||
action.setVisible(True)
|
||||
# Second pass: ensure no two visible separators are adjacent.
|
||||
# Second pass: ensure no two visible separators are adjacent, and no separators next to splitters.
|
||||
prev = None
|
||||
for action in toolbar_actions:
|
||||
if action.isVisible() and action.isSeparator():
|
||||
if prev and prev.isSeparator():
|
||||
action.setVisible(False)
|
||||
if action.isVisible():
|
||||
if action.isSeparator():
|
||||
# Hide separator if previous visible item was a separator or splitter
|
||||
if prev and (prev.isSeparator() or is_splitter_widget(prev)):
|
||||
action.setVisible(False)
|
||||
else:
|
||||
prev = action
|
||||
else:
|
||||
prev = action
|
||||
else:
|
||||
if action.isVisible():
|
||||
prev = action
|
||||
|
||||
if not toolbar_actions:
|
||||
return
|
||||
@@ -481,12 +508,31 @@ 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="This is a test label.")
|
||||
self.test_label = QLabel(text="Drag the splitter (⋮) to resize!")
|
||||
self.central_widget.layout = QVBoxLayout(self.central_widget)
|
||||
self.central_widget.layout.addWidget(self.test_label)
|
||||
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.addToolBar(self.toolbar)
|
||||
|
||||
# Example: Bare combobox (no container). Give it a stable starting width
|
||||
self.example_combo = QComboBox(parent=self)
|
||||
self.example_combo.addItems(["device_1", "device_2", "device_3"])
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"example_combo", WidgetAction(widget=self.example_combo)
|
||||
)
|
||||
|
||||
# Create a bundle with the combobox and a splitter
|
||||
self.bundle_combo_splitter = ToolbarBundle("example_combo", self.toolbar.components)
|
||||
self.bundle_combo_splitter.add_action("example_combo")
|
||||
# Add splitter; target the bare widget
|
||||
self.bundle_combo_splitter.add_splitter(
|
||||
name="splitter_example", target_widget=self.example_combo, min_width=100
|
||||
)
|
||||
|
||||
# Add other bundles
|
||||
self.toolbar.add_bundle(self.bundle_combo_splitter)
|
||||
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
|
||||
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))
|
||||
self.toolbar.connect_bundle(
|
||||
@@ -502,7 +548,9 @@ if __name__ == "__main__": # pragma: no cover
|
||||
text_position="under",
|
||||
),
|
||||
)
|
||||
self.toolbar.show_bundles(["performance", "plot_export"])
|
||||
|
||||
# Show bundles - notice how performance and plot_export appear compactly after splitter!
|
||||
self.toolbar.show_bundles(["example_combo", "performance", "plot_export"])
|
||||
self.toolbar.get_bundle("performance").add_action("save")
|
||||
self.toolbar.get_bundle("performance").add_action("text")
|
||||
self.toolbar.refresh()
|
||||
|
||||
@@ -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.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
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 = AdvancedDockArea(
|
||||
self.dock_area = BECDockArea(
|
||||
parent=self,
|
||||
object_name="dock_area",
|
||||
enable_profile_management=False,
|
||||
|
||||
@@ -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, Qt, QTimer
|
||||
from qtpy.QtCore import QByteArray, QSettings, QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget
|
||||
from shiboken6 import isValid
|
||||
@@ -302,6 +302,13 @@ 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)
|
||||
@@ -324,7 +331,9 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
if hasattr(widget, "widget_removed"):
|
||||
widget.widget_removed.connect(on_widget_destroyed)
|
||||
|
||||
dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget)
|
||||
dock.setMinimumSizeHintMode(
|
||||
CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidgetMinimumSize
|
||||
)
|
||||
dock_area_widget = None
|
||||
if tab_with is not None:
|
||||
if not isValid(tab_with):
|
||||
@@ -1189,8 +1198,7 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
if button is not None:
|
||||
button.setVisible(bool(visible))
|
||||
|
||||
# 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)
|
||||
apply()
|
||||
|
||||
def set_central_dock(self, dock: CDockWidget | QWidget | str) -> None:
|
||||
"""
|
||||
@@ -1302,11 +1310,7 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
apply_widget_icon=apply_widget_icon,
|
||||
)
|
||||
|
||||
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)
|
||||
self._create_dock_from_spec(spec)
|
||||
return widget
|
||||
|
||||
spec = self._build_creation_spec(
|
||||
@@ -1411,7 +1415,7 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QPushButton
|
||||
from qtpy.QtWidgets import QLabel, QMainWindow, QPushButton
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Literal, Mapping, Sequence
|
||||
|
||||
import slugify
|
||||
from bec_lib import bec_logger
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
from qtpy.QtCore import 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.advanced_dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
|
||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.dock_area.profile_utils import (
|
||||
SETTINGS_KEYS,
|
||||
default_profile_candidates,
|
||||
delete_profile_files,
|
||||
@@ -55,14 +55,12 @@ from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
|
||||
user_profile_candidates,
|
||||
write_manifest,
|
||||
)
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import (
|
||||
from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
|
||||
RestoreProfileDialog,
|
||||
SaveProfileDialog,
|
||||
)
|
||||
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 (
|
||||
from bec_widgets.widgets.containers.dock_area.settings.workspace_manager import WorkSpaceManager
|
||||
from bec_widgets.widgets.containers.dock_area.toolbar_components.workspace_actions import (
|
||||
WorkspaceConnection,
|
||||
workspace_bundle,
|
||||
)
|
||||
@@ -77,7 +75,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 import RingProgressBar
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.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
|
||||
@@ -90,7 +88,7 @@ _PROFILE_NAMESPACE_UNSET = object()
|
||||
PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")}
|
||||
|
||||
|
||||
class AdvancedDockArea(DockAreaWidget):
|
||||
class BECDockArea(DockAreaWidget):
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
USER_ACCESS = [
|
||||
@@ -246,11 +244,10 @@ class AdvancedDockArea(DockAreaWidget):
|
||||
if self._profile_exists("general", namespace):
|
||||
init_profile = "general"
|
||||
if 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))
|
||||
self._load_initial_profile(init_profile)
|
||||
|
||||
def _load_initial_profile(self, name: str) -> None:
|
||||
"""Load the initial profile after construction when the event loop is running."""
|
||||
"""Load the initial profile."""
|
||||
self.load_profile(name, start_empty=self._start_empty)
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
combo.blockSignals(True)
|
||||
@@ -1164,7 +1161,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
dispatcher = BECDispatcher(gui_id="ads")
|
||||
window = BECMainWindowNoRPC()
|
||||
|
||||
ads = AdvancedDockArea(mode="creator", enable_profile_management=True, root_widget=True)
|
||||
ads = BECDockArea(mode="creator", enable_profile_management=True, root_widget=True)
|
||||
|
||||
window.setCentralWidget(ads)
|
||||
window.show()
|
||||
@@ -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.advanced_dock_area.profile_utils import (
|
||||
from bec_widgets.widgets.containers.dock_area.profile_utils import (
|
||||
get_profile_info,
|
||||
is_quick_select,
|
||||
list_profiles,
|
||||
@@ -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.advanced_dock_area.profile_utils import list_quick_profiles
|
||||
from bec_widgets.widgets.containers.dock_area.profile_utils import list_quick_profiles
|
||||
|
||||
|
||||
class ProfileComboBox(QComboBox):
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -22,6 +21,7 @@ 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()
|
||||
self.notification_broker = BECNotificationBroker(parent=self)
|
||||
self._nc_margin = 16
|
||||
self._position_notification_centre()
|
||||
|
||||
@@ -115,14 +115,11 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
Prepare the BEC specific widgets in the status bar.
|
||||
"""
|
||||
|
||||
# Left: App‑ID 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)
|
||||
# Left: Beamline condition status toolbar (auto-fetches all conditions)
|
||||
self._status_toolbar = StatusToolBar(parent=self, names=None)
|
||||
self.status_bar.addWidget(self._status_toolbar)
|
||||
|
||||
# Add a separator after the app ID label
|
||||
# Add a separator after the status toolbar
|
||||
self._add_separator()
|
||||
|
||||
# Centre: Client‑info label (stretch=1 so it expands)
|
||||
@@ -341,13 +338,27 @@ 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 status bar.
|
||||
Display the app ID in the Help menu.
|
||||
"""
|
||||
if self.bec_dispatcher.cli_server is None:
|
||||
status_message = "Not connected"
|
||||
@@ -355,7 +366,8 @@ 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}"
|
||||
self._app_id_label.setText(status_message)
|
||||
if hasattr(self, "_app_id_action"):
|
||||
self._app_id_action.setText(status_message)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def display_client_message(self, msg: dict, meta: dict):
|
||||
|
||||
@@ -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 Signal
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QDoubleSpinBox
|
||||
|
||||
@@ -66,6 +66,13 @@ 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
|
||||
|
||||
@@ -32,6 +32,7 @@ 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
|
||||
@@ -125,11 +126,13 @@ 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 all_dev if self._check_device_filter(dev)]
|
||||
devs = [dev for dev in devs 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]
|
||||
@@ -190,6 +193,27 @@ 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."""
|
||||
@@ -379,6 +403,20 @@ 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:
|
||||
|
||||
@@ -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, LineEditFilterHandler
|
||||
from bec_widgets.utils.filter_io import FilterIO
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
@@ -17,6 +17,8 @@ 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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -27,12 +26,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()
|
||||
@@ -51,6 +50,7 @@ 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,6 +63,7 @@ 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.
|
||||
@@ -85,6 +86,10 @@ 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)
|
||||
@@ -181,21 +186,70 @@ 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, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
widget.setLayout(layout)
|
||||
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)
|
||||
|
||||
combo = DeviceComboBox()
|
||||
combo.devices = ["samx", "dev1", "dev2", "dev3", "dev4"]
|
||||
combo.set_first_element_as_empty = True
|
||||
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_()
|
||||
|
||||
@@ -31,12 +31,11 @@ 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__(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtCore import QSize, Qt, Signal
|
||||
from qtpy.QtWidgets import QComboBox, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
@@ -22,18 +21,27 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
client: BEC client object.
|
||||
config: Device input configuration.
|
||||
gui_id: GUI ID.
|
||||
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
||||
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.
|
||||
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 = True
|
||||
RPC = False
|
||||
|
||||
device_signal_changed = Signal(str)
|
||||
signal_reset = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -42,9 +50,13 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
config: DeviceSignalInputBaseConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
device: str | None = None,
|
||||
signal_filter: str | list[str] | None = None,
|
||||
signal_filter: list[Kind] | 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,
|
||||
store_signal_config: bool = True,
|
||||
require_device: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
@@ -57,26 +69,64 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.setMinimumSize(QSize(100, 0))
|
||||
self._set_first_element_as_empty = True
|
||||
# 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._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.
|
||||
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"""
|
||||
"""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.
|
||||
"""
|
||||
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:
|
||||
@@ -118,6 +168,63 @@ 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.
|
||||
@@ -166,6 +273,91 @@ 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."""
|
||||
@@ -176,22 +368,44 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
|
||||
@SafeSlot(str)
|
||||
def on_text_changed(self, text: str):
|
||||
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
|
||||
"""Validate and emit only when the signal is valid.
|
||||
For a positioner, the readback value has to be renamed to the device name.
|
||||
|
||||
Args:
|
||||
text (str): Text in the combobox.
|
||||
When using signal_class_filter, device validation is skipped.
|
||||
"""
|
||||
if self.validate_device(self.device) is False:
|
||||
return
|
||||
if self.validate_signal(text) is False:
|
||||
return
|
||||
self.device_signal_changed.emit(text)
|
||||
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;")
|
||||
|
||||
@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
|
||||
@@ -205,7 +419,14 @@ if __name__ == "__main__": # pragma: no cover
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
widget.setLayout(layout)
|
||||
box = SignalComboBox(device="samx")
|
||||
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)
|
||||
layout.addWidget(box)
|
||||
widget.show()
|
||||
app.exec_()
|
||||
|
||||
@@ -29,7 +29,7 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
|
||||
device_signal_changed = Signal(str)
|
||||
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
RPC = False
|
||||
ICON_NAME = "vital_signs"
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Module for the device configuration form widget for EpicsMotor, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV"""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Type
|
||||
from typing import Any, 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.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Module for custom input widgets used in device configuration templates."""
|
||||
|
||||
from ast import literal_eval
|
||||
from typing import Callable
|
||||
from typing import Any, 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: any) -> any:
|
||||
def _try_literal_eval(value: str) -> 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)
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ in DeviceTableRow entries.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from copy import deepcopy
|
||||
from typing import Any, Callable, Iterable, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, 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
|
||||
@@ -26,6 +28,9 @@ 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]]
|
||||
@@ -208,6 +213,11 @@ 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):
|
||||
@@ -267,15 +277,66 @@ 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._autosize_timer.stop()
|
||||
self.client.callbacks.remove(self.client_callback_id) # Unregister callback
|
||||
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
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -769,6 +830,51 @@ 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
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -832,7 +938,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, text="Loading device configurations...")
|
||||
self.set_busy(True)
|
||||
with self.table_sort_on_hold:
|
||||
self.clear_device_configs()
|
||||
cfgs_added = []
|
||||
@@ -842,12 +948,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, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@SafeSlot()
|
||||
def clear_device_configs(self):
|
||||
"""Clear the device configs. Skips validation per default."""
|
||||
self.set_busy(True, text="Clearing device configurations...")
|
||||
"""Clear the device configs. Skips validation by default."""
|
||||
self.set_busy(True)
|
||||
device_configs = self.get_device_config()
|
||||
with self.table_sort_on_hold:
|
||||
self._clear_table()
|
||||
@@ -856,7 +962,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, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@SafeSlot(list, bool)
|
||||
def add_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False):
|
||||
@@ -869,7 +975,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, text="Adding device configurations...")
|
||||
self.set_busy(True)
|
||||
already_in_table = []
|
||||
not_in_table = []
|
||||
with self.table_sort_on_hold:
|
||||
@@ -894,7 +1000,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, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@SafeSlot(list, bool)
|
||||
def update_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False):
|
||||
@@ -905,7 +1011,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, text="Loading device configurations...")
|
||||
self.set_busy(True)
|
||||
cfgs_updated = []
|
||||
with self.table_sort_on_hold:
|
||||
for cfg in device_configs:
|
||||
@@ -920,7 +1026,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, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@SafeSlot(list)
|
||||
def remove_device_configs(self, device_configs: _DeviceCfgIter):
|
||||
@@ -930,7 +1036,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
Args:
|
||||
device_configs (dict[str, dict]): The device configs to remove.
|
||||
"""
|
||||
self.set_busy(True, text="Removing device configurations...")
|
||||
self.set_busy(True)
|
||||
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])
|
||||
@@ -939,7 +1045,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, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@SafeSlot(str)
|
||||
def remove_device(self, device_name: str):
|
||||
@@ -949,11 +1055,11 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
Args:
|
||||
device_name (str): The name of the device to remove.
|
||||
"""
|
||||
self.set_busy(True, text=f"Removing device configuration for {device_name}...")
|
||||
self.set_busy(True)
|
||||
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, text="")
|
||||
self.set_busy(False)
|
||||
return
|
||||
with self.table_sort_on_hold:
|
||||
self._remove_rows_by_name([row_data.data["name"]])
|
||||
@@ -961,7 +1067,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, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@SafeSlot(list)
|
||||
def update_multiple_device_validations(self, validation_results: _ValidationResultIter):
|
||||
@@ -973,9 +1079,15 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
Args:
|
||||
device_configs (Iterable[dict[str, Any]]): The device configs to update.
|
||||
"""
|
||||
self.set_busy(True, text="Updating device validations in session...")
|
||||
self.set_busy(True)
|
||||
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.")
|
||||
@@ -984,7 +1096,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, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@SafeSlot(dict, int, int, str)
|
||||
def update_device_validation(
|
||||
@@ -997,13 +1109,13 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
Args:
|
||||
|
||||
"""
|
||||
self.set_busy(True, text="Updating device validation status...")
|
||||
self.set_busy(True)
|
||||
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, text="")
|
||||
self.set_busy(False)
|
||||
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
|
||||
@@ -1013,4 +1125,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, text="")
|
||||
self.set_busy(False)
|
||||
|
||||
@@ -69,11 +69,12 @@ 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)
|
||||
self.tester = StaticDeviceTest(config_dict=test_config, device_manager_ds=device_manager_ds)
|
||||
self.signals = DeviceTestResult()
|
||||
self.device_config = device_model.device_config
|
||||
self.enable_connect = enable_connect
|
||||
@@ -264,7 +265,6 @@ 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,7 +282,6 @@ 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
|
||||
@@ -548,9 +547,10 @@ 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 or skip_validation is True: # Remove requested
|
||||
if not added: # Remove requested, holds priority over skip_validation
|
||||
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,29 +563,39 @@ 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:
|
||||
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)
|
||||
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
|
||||
continue
|
||||
if not self._device_already_exists(cfg.get("name")): # New device case
|
||||
# New device case, that is not in BEC session
|
||||
if not self._device_already_exists(cfg.get("name")):
|
||||
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)
|
||||
self._remove_device_config(cfg, force_remove=True)
|
||||
self._add_device_config(
|
||||
cfg, connect=connect, force_connect=force_connect, timeout=timeout
|
||||
)
|
||||
@@ -661,13 +671,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) -> None:
|
||||
def _remove_device(self, device_name: str, force_remove: bool = False) -> 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:
|
||||
if device_name in self._keep_visible_after_validation and not force_remove:
|
||||
logger.debug(
|
||||
f"Device with name {device_name} is set to be kept visible after validation, not removing it."
|
||||
)
|
||||
@@ -676,9 +686,11 @@ 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]) -> None:
|
||||
def _remove_device_config(
|
||||
self, device_config: dict[str, Any], force_remove: bool = False
|
||||
) -> None:
|
||||
device_name = device_config.get("name")
|
||||
self._remove_device(device_name)
|
||||
self._remove_device(device_name, force_remove=force_remove)
|
||||
|
||||
@SafeSlot(str, dict, bool, bool, float)
|
||||
def _on_request_rerun_validation(
|
||||
@@ -741,11 +753,15 @@ 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:
|
||||
|
||||
@@ -31,27 +31,14 @@ 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):
|
||||
"""
|
||||
@@ -308,13 +295,11 @@ 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):
|
||||
@@ -323,9 +308,7 @@ 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()
|
||||
|
||||
@@ -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.advanced_dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.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
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['vscode.py']}
|
||||
@@ -1,203 +0,0 @@
|
||||
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()
|
||||
@@ -1453,7 +1453,7 @@ class Heatmap(ImageBase):
|
||||
# Post Processing
|
||||
################################################################################
|
||||
|
||||
@SafeProperty(bool)
|
||||
@SafeProperty(bool, auto_emit=True)
|
||||
def fft(self) -> bool:
|
||||
"""
|
||||
Whether FFT postprocessing is enabled.
|
||||
@@ -1470,7 +1470,7 @@ class Heatmap(ImageBase):
|
||||
"""
|
||||
self.main_image.fft = enable
|
||||
|
||||
@SafeProperty(bool)
|
||||
@SafeProperty(bool, auto_emit=True)
|
||||
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)
|
||||
@SafeProperty(bool, auto_emit=True)
|
||||
def transpose(self) -> bool:
|
||||
"""
|
||||
Whether the image is transposed.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ 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
|
||||
@@ -131,8 +132,9 @@ class ImageLayerManager:
|
||||
image.setZValue(z_position)
|
||||
image.removed.connect(self._remove_destroyed_layer)
|
||||
|
||||
# FIXME: For now, we hard-code the default color map here. In the future, this should be configurable.
|
||||
image.color_map = "plasma"
|
||||
color_map = getattr(getattr(self.parent, "config", None), "color_map", None)
|
||||
if color_map:
|
||||
image.color_map = color_map
|
||||
|
||||
self.layers[name] = ImageLayer(name=name, image=image, sync=sync)
|
||||
self.plot_item.addItem(image)
|
||||
@@ -249,6 +251,8 @@ 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)
|
||||
@@ -460,18 +464,20 @@ class ImageBase(PlotBase):
|
||||
self.setProperty("autorange", False)
|
||||
|
||||
if style == "simple":
|
||||
self._color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
|
||||
cmap = Colors.get_colormap(self.config.color_map)
|
||||
self._color_bar = pg.ColorBarItem(colorMap=cmap)
|
||||
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._color_bar.gradient.loadPreset(self.config.color_map)
|
||||
self.config.color_bar = "full"
|
||||
self._apply_colormap_to_colorbar(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)
|
||||
@@ -484,6 +490,37 @@ 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
|
||||
|
||||
@@ -754,11 +791,11 @@ class ImageBase(PlotBase):
|
||||
layer.image.color_map = value
|
||||
|
||||
if self._color_bar:
|
||||
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:
|
||||
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}"
|
||||
)
|
||||
return
|
||||
|
||||
@SafeProperty("QPointF")
|
||||
|
||||
@@ -119,7 +119,8 @@ class ImageItem(BECConnector, pg.ImageItem):
|
||||
"""Set a new color map."""
|
||||
try:
|
||||
self.config.color_map = value
|
||||
self.setColorMap(value)
|
||||
cmap = Colors.get_colormap(self.config.color_map)
|
||||
self.setColorMap(cmap)
|
||||
except ValidationError:
|
||||
logger.error(f"Invalid colormap '{value}' provided.")
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
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,
|
||||
)
|
||||
@@ -300,9 +300,14 @@ 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
|
||||
@@ -315,7 +320,6 @@ 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")
|
||||
@@ -324,6 +328,11 @@ 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()
|
||||
@@ -367,8 +376,11 @@ 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)
|
||||
@@ -376,15 +388,25 @@ 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from qtpy.QtWidgets import QHBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QHBoxLayout, QSizePolicy, QWidget
|
||||
|
||||
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
@@ -8,6 +8,8 @@ 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)
|
||||
|
||||
@@ -17,18 +19,22 @@ 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(0)
|
||||
layout.addWidget(self.motor_x)
|
||||
layout.addWidget(self.motor_y)
|
||||
layout.setSpacing(2)
|
||||
layout.addWidget(self.motor_x, stretch=1) # Equal stretch
|
||||
layout.addWidget(self.motor_y, stretch=1) # Equal stretch
|
||||
|
||||
def set_motors(self, motor_x: str | None, motor_y: str | None) -> None:
|
||||
"""Set the displayed motors without emitting selection signals."""
|
||||
@@ -65,6 +71,9 @@ 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.
|
||||
|
||||
@@ -79,6 +88,14 @@ 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
|
||||
|
||||
|
||||
|
||||
@@ -446,7 +446,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
else:
|
||||
logger.warning(f"Property {key} not found.")
|
||||
|
||||
@SafeProperty(str, doc="The title of the axes.")
|
||||
@SafeProperty(str, auto_emit=True, doc="The title of the axes.")
|
||||
def title(self) -> str:
|
||||
"""
|
||||
Set title of the plot.
|
||||
@@ -462,9 +462,8 @@ class PlotBase(BECWidget, QWidget):
|
||||
value(str): The title to set.
|
||||
"""
|
||||
self.plot_item.setTitle(value)
|
||||
self.property_changed.emit("title", value)
|
||||
|
||||
@SafeProperty(str, doc="The text of the x label")
|
||||
@SafeProperty(str, auto_emit=True, doc="The text of the x label")
|
||||
def x_label(self) -> str:
|
||||
"""
|
||||
The set label for the x-axis.
|
||||
@@ -481,7 +480,6 @@ 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:
|
||||
@@ -535,7 +533,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
if self.plot_item.getAxis("bottom").isVisible():
|
||||
self.plot_item.setLabel("bottom", text=final_label)
|
||||
|
||||
@SafeProperty(str, doc="The text of the y label")
|
||||
@SafeProperty(str, auto_emit=True, doc="The text of the y label")
|
||||
def y_label(self) -> str:
|
||||
"""
|
||||
The set label for the y-axis.
|
||||
@@ -551,7 +549,6 @@ 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:
|
||||
@@ -772,7 +769,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
"""
|
||||
self.y_limits = (self.y_lim[0], value)
|
||||
|
||||
@SafeProperty(bool, doc="Show grid on the x-axis.")
|
||||
@SafeProperty(bool, auto_emit=True, doc="Show grid on the x-axis.")
|
||||
def x_grid(self) -> bool:
|
||||
"""
|
||||
Show grid on the x-axis.
|
||||
@@ -788,9 +785,8 @@ 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, doc="Show grid on the y-axis.")
|
||||
@SafeProperty(bool, auto_emit=True, doc="Show grid on the y-axis.")
|
||||
def y_grid(self) -> bool:
|
||||
"""
|
||||
Show grid on the y-axis.
|
||||
@@ -806,9 +802,8 @@ 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, doc="Set X-axis to log scale if True, linear if False.")
|
||||
@SafeProperty(bool, auto_emit=True, 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.
|
||||
@@ -824,9 +819,8 @@ 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, doc="Set Y-axis to log scale if True, linear if False.")
|
||||
@SafeProperty(bool, auto_emit=True, 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.
|
||||
@@ -842,9 +836,8 @@ 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, doc="Show the outer axes of the plot widget.")
|
||||
@SafeProperty(bool, auto_emit=True, doc="Show the outer axes of the plot widget.")
|
||||
def outer_axes(self) -> bool:
|
||||
"""
|
||||
Show the outer axes of the plot widget.
|
||||
@@ -863,9 +856,8 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.plot_item.showAxis("right", value)
|
||||
|
||||
self._outer_axes_visible = value
|
||||
self.property_changed.emit("outer_axes", value)
|
||||
|
||||
@SafeProperty(bool, doc="Show inner axes of the plot widget.")
|
||||
@SafeProperty(bool, auto_emit=True, doc="Show inner axes of the plot widget.")
|
||||
def inner_axes(self) -> bool:
|
||||
"""
|
||||
Show inner axes of the plot widget.
|
||||
@@ -886,7 +878,6 @@ 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:
|
||||
@@ -1110,7 +1101,9 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.unhook_crosshair()
|
||||
|
||||
@SafeProperty(
|
||||
int, doc="Minimum decimal places for crosshair when dynamic precision is enabled."
|
||||
int,
|
||||
auto_emit=True,
|
||||
doc="Minimum decimal places for crosshair when dynamic precision is enabled.",
|
||||
)
|
||||
def minimal_crosshair_precision(self) -> int:
|
||||
"""
|
||||
@@ -1130,7 +1123,6 @@ 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:
|
||||
|
||||
@@ -82,7 +82,7 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
|
||||
# Color settings
|
||||
self._background_color = QColor(30, 30, 30)
|
||||
self._progress_color = accent_colors.highlight # QColor(210, 55, 130)
|
||||
self._progress_color = accent_colors.highlight
|
||||
|
||||
self._completed_color = accent_colors.success
|
||||
self._border_color = QColor(50, 50, 50)
|
||||
@@ -91,7 +91,6 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
|
||||
# Progress‑bar state handling
|
||||
self._state = ProgressState.NORMAL
|
||||
# self._state_colors = dict(PROGRESS_STATE_COLORS)
|
||||
|
||||
self._state_colors = {
|
||||
ProgressState.NORMAL: accent_colors.default,
|
||||
@@ -109,8 +108,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)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""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())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['device_initialization_progress_bar.py']}
|
||||
@@ -5,17 +5,19 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||
from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import (
|
||||
DeviceInitializationProgressBar,
|
||||
)
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='VSCodeEditor' name='vs_code_editor'>
|
||||
<widget class='DeviceInitializationProgressBar' name='device_initialization_progress_bar'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class VSCodeEditorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
class DeviceInitializationProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
@@ -23,20 +25,20 @@ class VSCodeEditorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = VSCodeEditor(parent)
|
||||
t = DeviceInitializationProgressBar(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Developer"
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(VSCodeEditor.ICON_NAME)
|
||||
return designer_material_icon(DeviceInitializationProgressBar.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "vs_code_editor"
|
||||
return "device_initialization_progress_bar"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
@@ -48,10 +50,10 @@ class VSCodeEditorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "VSCodeEditor"
|
||||
return "DeviceInitializationProgressBar"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
return "A progress bar that displays the progress of device initialization."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -6,9 +6,11 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.editors.vscode.vs_code_editor_plugin import VSCodeEditorPlugin
|
||||
from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar_plugin import (
|
||||
DeviceInitializationProgressBarPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(VSCodeEditorPlugin())
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceInitializationProgressBarPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
@@ -1 +0,0 @@
|
||||
from .ring_progress_bar import RingProgressBar
|
||||
|
||||
@@ -1,130 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
from typing import TYPE_CHECKING, Callable, Literal
|
||||
|
||||
from bec_lib.endpoints import EndpointInfo, MessageEndpoints
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy import QtGui
|
||||
from qtpy.QtCore import QObject
|
||||
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 bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
logger = bec_logger.logger
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import (
|
||||
RingProgressContainerWidget,
|
||||
)
|
||||
|
||||
|
||||
class ProgressbarConfig(ConnectionConfig):
|
||||
value: int | float | None = Field(0, description="Value for the progress bars.")
|
||||
direction: int | None = Field(
|
||||
value: int | float = Field(0, description="Value for the progress bars.")
|
||||
direction: int = Field(
|
||||
-1, description="Direction of the progress bars. -1 for clockwise, 1 for counter-clockwise."
|
||||
)
|
||||
color: str | tuple | None = Field(
|
||||
color: str | tuple = 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 | None = Field(
|
||||
background_color: str | tuple = Field(
|
||||
(200, 200, 200, 50),
|
||||
description="Background color for the progress bars. Can be tuple (R, G, B, A) or string HEX Code.",
|
||||
)
|
||||
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(
|
||||
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(
|
||||
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 | 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."
|
||||
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."
|
||||
)
|
||||
connections: ProgressbarConnections | None = Field(
|
||||
default_factory=ProgressbarConnections, description="Connections 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'.",
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
class Ring(BECConnector, QWidget):
|
||||
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",
|
||||
"reset_connection",
|
||||
"set_precision",
|
||||
]
|
||||
RPC = True
|
||||
|
||||
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
|
||||
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
|
||||
self.RID = None
|
||||
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._gap = 5
|
||||
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):
|
||||
"""
|
||||
@@ -133,11 +91,7 @@ class Ring(BECConnector, QObject):
|
||||
Args:
|
||||
value(int | float): Value for the ring widget
|
||||
"""
|
||||
self.config.value = round(
|
||||
float(max(self.config.min_value, min(self.config.max_value, value))),
|
||||
self.config.precision,
|
||||
)
|
||||
self.parent_progress_widget.update()
|
||||
self.value = value
|
||||
|
||||
def set_color(self, color: str | tuple):
|
||||
"""
|
||||
@@ -146,20 +100,53 @@ class Ring(BECConnector, QObject):
|
||||
Args:
|
||||
color(str | tuple): Color for the ring widget. Can be HEX code or tuple (R, G, B, A).
|
||||
"""
|
||||
self.config.color = color
|
||||
self.color = self.convert_color(color)
|
||||
self.parent_progress_widget.update()
|
||||
self._color = self.convert_color(color)
|
||||
self.config.color = self._color.name()
|
||||
|
||||
def set_background(self, color: str | tuple):
|
||||
# Automatically set background color
|
||||
if self.config.link_colors:
|
||||
self._auto_set_background_color()
|
||||
|
||||
self.update()
|
||||
|
||||
def set_background(self, color: str | tuple | QColor):
|
||||
"""
|
||||
Set the background color for the ring widget
|
||||
Set the background color for the ring widget. The background color is only used when colors are not linked.
|
||||
|
||||
Args:
|
||||
color(str | tuple): Background color for the ring widget. Can be HEX code or tuple (R, G, B, A).
|
||||
"""
|
||||
self.config.background_color = color
|
||||
self.color = self.convert_color(color)
|
||||
self.parent_progress_widget.update()
|
||||
# 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()
|
||||
|
||||
def set_line_width(self, width: int):
|
||||
"""
|
||||
@@ -169,7 +156,7 @@ class Ring(BECConnector, QObject):
|
||||
width(int): Line width for the ring widget
|
||||
"""
|
||||
self.config.line_width = width
|
||||
self.parent_progress_widget.update()
|
||||
self.update()
|
||||
|
||||
def set_min_max_values(self, min_value: int | float, max_value: int | float):
|
||||
"""
|
||||
@@ -181,35 +168,21 @@ class Ring(BECConnector, QObject):
|
||||
"""
|
||||
self.config.min_value = min_value
|
||||
self.config.max_value = max_value
|
||||
self.parent_progress_widget.update()
|
||||
self.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.start_position = start_angle * 16
|
||||
self.parent_progress_widget.update()
|
||||
self.update()
|
||||
|
||||
@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):
|
||||
def set_update(
|
||||
self, mode: Literal["manual", "scan", "device"], device: str = "", signal: str = ""
|
||||
):
|
||||
"""
|
||||
Set the update mode for the ring widget.
|
||||
Modes:
|
||||
@@ -220,47 +193,167 @@ class Ring(BECConnector, QObject):
|
||||
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"
|
||||
"""
|
||||
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
|
||||
|
||||
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()
|
||||
)
|
||||
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))
|
||||
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.parent_progress_widget.enable_auto_updates(False)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported mode: {mode}")
|
||||
|
||||
def set_connections(self, slot: str, endpoint: str | EndpointInfo):
|
||||
def set_precision(self, precision: int):
|
||||
"""
|
||||
Set the connections for the ring widget
|
||||
Set the precision for the ring widget.
|
||||
|
||||
Args:
|
||||
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.
|
||||
precision(int): Precision for the ring widget
|
||||
"""
|
||||
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)
|
||||
self.config.precision = precision
|
||||
self.update()
|
||||
|
||||
def reset_connection(self):
|
||||
def set_direction(self, direction: int):
|
||||
"""
|
||||
Reset the connections for the ring widget. Disconnect the current slot and endpoint.
|
||||
"""
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.config.connections.slot, self.config.connections.endpoint
|
||||
)
|
||||
self.config.connections = ProgressbarConnections()
|
||||
Set the direction for the ring widget.
|
||||
|
||||
Args:
|
||||
direction(int): Direction for the ring widget. -1 for clockwise, 1 for counter-clockwise.
|
||||
"""
|
||||
self.config.direction = direction
|
||||
self.update()
|
||||
|
||||
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.
|
||||
@@ -273,8 +366,9 @@ class Ring(BECConnector, QObject):
|
||||
if current_RID != self.RID:
|
||||
self.set_min_max_values(0, msg.get("max_value", 100))
|
||||
self.set_value(msg.get("value", 0))
|
||||
self.parent_progress_widget.update()
|
||||
self.update()
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback(self, msg, meta):
|
||||
"""
|
||||
Update the ring widget with the device readback.
|
||||
@@ -283,11 +377,242 @@ class Ring(BECConnector, QObject):
|
||||
msg(dict): Message with the device readback
|
||||
meta(dict): Metadata for the message
|
||||
"""
|
||||
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")
|
||||
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
|
||||
self.set_value(value)
|
||||
self.parent_progress_widget.update()
|
||||
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())
|
||||
|
||||
@@ -1,348 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
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 qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import Colors, ConnectionConfig, EntryValidator
|
||||
from bec_widgets.utils import Colors
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring, RingConfig
|
||||
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
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
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):
|
||||
class RingProgressContainerWidget(QWidget):
|
||||
"""
|
||||
Show the progress of devices, scans or custom values in the form of ring progress bars.
|
||||
A container widget for the Ring Progress Bar widget.
|
||||
It holds the rings and manages their layout and painting.
|
||||
"""
|
||||
|
||||
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)
|
||||
)
|
||||
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())
|
||||
self.initialize_bars()
|
||||
|
||||
self.enable_auto_updates(self.config.auto_updates)
|
||||
self.initialize_center_label()
|
||||
|
||||
@property
|
||||
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 num_bars(self) -> int:
|
||||
return len(self.rings)
|
||||
|
||||
def initialize_bars(self):
|
||||
"""
|
||||
Initialize the progress bars.
|
||||
"""
|
||||
start_positions = [90 * 16] * self.config.num_bars
|
||||
directions = [-1] * self.config.num_bars
|
||||
for _ in range(self.num_bars):
|
||||
self.add_ring()
|
||||
|
||||
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]
|
||||
if self.color_map:
|
||||
self.set_colors_from_map(self.color_map)
|
||||
|
||||
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:
|
||||
def add_ring(self, config: dict | None = None) -> Ring:
|
||||
"""
|
||||
Add a new progress bar.
|
||||
Add a new ring to the container.
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments for the new progress bar.
|
||||
config(dict | None): Optional configuration dictionary for the ring.
|
||||
|
||||
Returns:
|
||||
Ring: Ring object.
|
||||
Ring: The newly added ring object.
|
||||
"""
|
||||
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
|
||||
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
|
||||
|
||||
def remove_ring(self, index: int):
|
||||
def remove_ring(self, index: int | None = None):
|
||||
"""
|
||||
Remove a progress bar by index.
|
||||
Remove a ring from the container.
|
||||
|
||||
Args:
|
||||
index(int): Index of the progress bar to remove.
|
||||
index(int | None): Index of the ring to remove. If None, removes the last ring.
|
||||
"""
|
||||
ring = self._find_ring_by_index(index)
|
||||
self._cleanup_ring(ring)
|
||||
self.update()
|
||||
|
||||
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)
|
||||
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()
|
||||
# del ring
|
||||
|
||||
def _reindex_rings(self):
|
||||
"""
|
||||
Reindex the progress bars.
|
||||
"""
|
||||
for i, ring in enumerate(self._rings):
|
||||
ring.config.index = i
|
||||
|
||||
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.
|
||||
"""
|
||||
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.rings.pop(index)
|
||||
# Update gaps for remaining rings
|
||||
for i, r in enumerate(self.rings):
|
||||
r.gap = self.gap * i
|
||||
self.update()
|
||||
|
||||
def set_min_max_values(
|
||||
self,
|
||||
min_values: int | float | list[int | float],
|
||||
max_values: int | float | list[int | float],
|
||||
):
|
||||
def initialize_center_label(self):
|
||||
"""
|
||||
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.
|
||||
Initialize the center label.
|
||||
"""
|
||||
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()
|
||||
layout = self.layout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
def set_number_of_bars(self, num_bars: int):
|
||||
self.center_label = QLabel("", parent=self)
|
||||
self.center_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(self.center_label)
|
||||
|
||||
def _calculate_minimum_size(self):
|
||||
"""
|
||||
Set the number of progress bars to display.
|
||||
|
||||
Args:
|
||||
num_bars(int): Number of progress bars to display.
|
||||
Calculate the minimum size of the widget.
|
||||
"""
|
||||
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 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)
|
||||
|
||||
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)
|
||||
return QSize(diameter, diameter)
|
||||
|
||||
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):
|
||||
def get_ring_line_widths(self):
|
||||
"""
|
||||
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.
|
||||
Get the line widths of the rings.
|
||||
"""
|
||||
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()
|
||||
if not self.rings:
|
||||
return [10]
|
||||
ring_widths = [ring.config.line_width for ring in self.rings]
|
||||
return ring_widths
|
||||
|
||||
def get_max_ring_size(self) -> int:
|
||||
"""
|
||||
Get the size of the rings.
|
||||
"""
|
||||
if not self.rings:
|
||||
return 10
|
||||
ring_widths = self.get_ring_line_widths()
|
||||
return max(ring_widths)
|
||||
|
||||
def sizeHint(self):
|
||||
min_size = self._calculate_minimum_size()
|
||||
return min_size
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""
|
||||
Handle resize events to update ring geometries.
|
||||
"""
|
||||
super().resizeEvent(event)
|
||||
for ring in self.rings:
|
||||
ring.setGeometry(self.rect())
|
||||
|
||||
def set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "RGB"):
|
||||
"""
|
||||
@@ -356,12 +162,14 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
raise ValueError(
|
||||
f"Colormap '{colormap}' not found in the current installation of pyqtgraph"
|
||||
)
|
||||
colors = Colors.golden_angle_color(colormap, self.config.num_bars, color_format)
|
||||
colors = Colors.golden_angle_color(colormap, self.num_bars, color_format)
|
||||
self.set_colors_directly(colors)
|
||||
self.config.color_map = colormap
|
||||
self.color_map = colormap
|
||||
self.update()
|
||||
|
||||
def set_colors_directly(self, colors: list[str | tuple] | str | tuple, bar_index: int = None):
|
||||
def set_colors_directly(
|
||||
self, colors: list[str | tuple] | str | tuple, bar_index: int | None = None
|
||||
):
|
||||
"""
|
||||
Set the colors for the progress bars directly.
|
||||
|
||||
@@ -370,170 +178,16 @@ class RingProgressBar(BECWidget, 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._bar_index_check(bar_index)
|
||||
ring = self._find_ring_by_index(bar_index)
|
||||
ring.set_color(colors)
|
||||
bar_index = self._validate_index(bar_index)
|
||||
self.rings[bar_index].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.
|
||||
@@ -550,101 +204,249 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
)
|
||||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
if len(items) < self.config.num_bars:
|
||||
if len(items) < self.num_bars:
|
||||
last_item = items[-1]
|
||||
items.extend([last_item] * (self.config.num_bars - len(items)))
|
||||
elif len(items) > self.config.num_bars:
|
||||
items = items[: self.config.num_bars]
|
||||
items.extend([last_item] * (self.num_bars - len(items)))
|
||||
elif len(items) > self.num_bars:
|
||||
items = items[: self.num_bars]
|
||||
return items
|
||||
|
||||
def _bar_index_check(self, bar_index: int):
|
||||
def _validate_index(self, index: int) -> int:
|
||||
"""
|
||||
Utility method to check if the bar index is within the range of the number of progress bars.
|
||||
Check if the provided index is valid for the number of bars.
|
||||
|
||||
Args:
|
||||
bar_index(int): Index of the progress bar to set the value for.
|
||||
index(int): Index to check.
|
||||
Returns:
|
||||
int: Validated 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
|
||||
try:
|
||||
self.rings[index]
|
||||
except IndexError:
|
||||
raise IndexError(f"Index {index} is out of range for {self.num_bars} rings.")
|
||||
return index
|
||||
|
||||
def clear_all(self):
|
||||
for ring in self._rings:
|
||||
ring.reset_connection()
|
||||
self._rings.clear()
|
||||
"""
|
||||
Clear all rings from the widget.
|
||||
"""
|
||||
for ring in self.rings:
|
||||
ring.close()
|
||||
ring.deleteLater()
|
||||
self.rings = []
|
||||
self.update()
|
||||
self.initialize_bars()
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
def cleanup(self):
|
||||
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()
|
||||
self.ring_progress_bar.clear_all()
|
||||
self.ring_progress_bar.close()
|
||||
self.ring_progress_bar.deleteLater()
|
||||
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_())
|
||||
|
||||
@@ -51,7 +51,7 @@ class RingProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return "RingProgressBar"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
return "RingProgressBar"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
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)
|
||||
235
bec_widgets/widgets/progress/ring_progress_bar/ring_settings.ui
Normal file
235
bec_widgets/widgets/progress/ring_progress_bar/ring_settings.ui
Normal file
@@ -0,0 +1,235 @@
|
||||
<?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>
|
||||
@@ -38,6 +38,8 @@ 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(
|
||||
@@ -73,6 +75,13 @@ 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")
|
||||
|
||||
@@ -324,12 +324,10 @@ 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
|
||||
|
||||
@@ -15,6 +15,7 @@ class SignalDisplay(BECWidget, QWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
device: str = "",
|
||||
config: ConnectionConfig = None,
|
||||
@@ -24,7 +25,14 @@ class SignalDisplay(BECWidget, QWidget):
|
||||
):
|
||||
"""A widget to display all the signals from a given device, and allow getting
|
||||
a fresh reading."""
|
||||
super().__init__(client, config, gui_id, theme_update, **kwargs)
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
client=client,
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
theme_update=theme_update,
|
||||
**kwargs,
|
||||
)
|
||||
self.get_bec_shortcuts()
|
||||
self._layout = QVBoxLayout()
|
||||
self.setLayout(self._layout)
|
||||
@@ -72,6 +80,7 @@ class SignalDisplay(BECWidget, QWidget):
|
||||
]:
|
||||
self._content_layout.addWidget(
|
||||
SignalLabel(
|
||||
parent=self,
|
||||
device=self._device,
|
||||
signal=sig,
|
||||
show_select_button=False,
|
||||
@@ -81,6 +90,7 @@ class SignalDisplay(BECWidget, QWidget):
|
||||
else:
|
||||
self._content_layout.addWidget(
|
||||
SignalLabel(
|
||||
parent=self,
|
||||
device=self._device,
|
||||
signal=self._device,
|
||||
show_select_button=False,
|
||||
|
||||
@@ -177,12 +177,10 @@ 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
|
||||
|
||||
@@ -494,6 +494,7 @@ if __name__ == "__main__":
|
||||
w.setLayout(QVBoxLayout())
|
||||
w.layout().addWidget(
|
||||
SignalLabel(
|
||||
parent=w,
|
||||
device="samx",
|
||||
signal="readback",
|
||||
custom_label="custom label:",
|
||||
@@ -501,7 +502,9 @@ if __name__ == "__main__":
|
||||
show_select_button=False,
|
||||
)
|
||||
)
|
||||
w.layout().addWidget(SignalLabel(device="samy", signal="readback", show_default_units=True))
|
||||
w.layout().addWidget(
|
||||
SignalLabel(parent=w, device="samy", signal="readback", show_default_units=True)
|
||||
)
|
||||
l = SignalLabel()
|
||||
l.device = "bpm4i"
|
||||
l.signal = "bpm4i"
|
||||
|
||||
@@ -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(monitor='eiger', monitor_type='2d')
|
||||
img_widget.image(device_name='eiger', device_entry='preview')
|
||||
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(monitor='waveform', monitor_type='1d')
|
||||
img_widget.image(device_name='waveform', device_entry='data')
|
||||
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(monitor='eiger', monitor_type='2d')
|
||||
img_widget = fig.image(device_name='eiger', device_entry='preview')
|
||||
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(monitor='waveform', monitor_type='1d')
|
||||
img_widget = fig.image(device_name='waveform', device_entry='data')
|
||||
img_widget.set_title("Line Detector Data")
|
||||
```
|
||||
|
||||
|
||||
@@ -24,11 +24,13 @@ 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('my_new_dock_area') # Create a new dock area
|
||||
progress = dock_area.new().new(gui.available_widgets.RingProgressBar)
|
||||
dock_area = gui.new() # Create a new dock area
|
||||
progress = dock_area.new(gui.available_widgets.RingProgressBar)
|
||||
|
||||
# Customize the size of the progress ring
|
||||
progress.set_line_widths(20)
|
||||
# Add a ring to the RingProgressBar
|
||||
progress.add_ring()
|
||||
ring = progress.rings[0]
|
||||
ring.set_value(50) # Set the progress value to 50
|
||||
```
|
||||
|
||||
## Example 2 - Adding Multiple Rings to Track Parallel Tasks
|
||||
@@ -40,8 +42,7 @@ By default, the `RingProgressBar` widget displays a single ring. You can add add
|
||||
progress.add_ring()
|
||||
|
||||
# Customize the rings
|
||||
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
|
||||
progress.rings[1].set_value(30) # Set the second ring to 30
|
||||
```
|
||||
|
||||
## Example 3 - Integrating with Device Readback and Scans
|
||||
@@ -56,44 +57,6 @@ 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
|
||||
|
||||
@@ -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("eiger")
|
||||
im_item = im.image(device_name="eiger", device_entry="preview")
|
||||
|
||||
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__ == "AdvancedDockArea"
|
||||
assert gui._ipython_registry[mw._gui_id].__class__.__name__ == "BECDockArea"
|
||||
|
||||
xw = gui.new("X")
|
||||
xw.delete_all()
|
||||
assert xw.__class__.__name__ == "RPCReference"
|
||||
assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "AdvancedDockArea"
|
||||
assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "BECDockArea"
|
||||
assert len(gui.windows) == 2
|
||||
|
||||
assert gui._gui_is_alive()
|
||||
|
||||
@@ -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(monitor="eiger")
|
||||
im.image(device_name="eiger", device_entry="preview")
|
||||
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(monitor="eiger")
|
||||
im.image(device_name="eiger", device_entry="preview")
|
||||
|
||||
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_monitor_2d("eiger"))[
|
||||
"data"
|
||||
].data
|
||||
last_image_device = client.connector.get_last(
|
||||
MessageEndpoints.device_preview("eiger", "preview")
|
||||
)["data"].data
|
||||
last_image_plot = im.main_image.get_data()
|
||||
|
||||
# check plotted data
|
||||
|
||||
@@ -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("eiger")
|
||||
im.image(device_name="eiger", device_entry="preview")
|
||||
motor_map = dock_area.new("MotorMap")
|
||||
motor_map.map("samx", "samy")
|
||||
plt_z = dock_area.new("Waveform")
|
||||
@@ -23,7 +23,8 @@ def test_rpc_reference_objects(connected_client_gui_obj):
|
||||
|
||||
assert len(plt_z.curves) == 1
|
||||
assert len(plt.curves) == 1
|
||||
assert im.monitor == "eiger"
|
||||
assert im.device_name == "eiger"
|
||||
assert im.device_entry == "preview"
|
||||
|
||||
assert isinstance(im.main_image, RPCReference)
|
||||
image_item = gui._ipython_registry.get(im.main_image._gui_id, None)
|
||||
|
||||
@@ -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 4
|
||||
top_level_widgets_count = 12
|
||||
# Number of top level widgets, should be 5
|
||||
top_level_widgets_count = 13
|
||||
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 2
|
||||
# Number of widgets with parent_id == None, should be 3
|
||||
widgets = [
|
||||
widget for widget in gui._server_registry.values() if widget["config"]["parent_id"] is None
|
||||
]
|
||||
assert len(widgets) == 2
|
||||
assert len(widgets) == 3
|
||||
|
||||
# Test all relevant widgets
|
||||
for object_name in gui.available_widgets.__dict__:
|
||||
@@ -115,40 +115,43 @@ 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) == 2
|
||||
assert len(widgets) == 3
|
||||
|
||||
#############################
|
||||
####### 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:
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
|
||||
@@ -221,95 +222,6 @@ 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."""
|
||||
@@ -322,7 +234,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(dev.eiger)
|
||||
img = widget.image(device_name=dev.eiger.name, device_entry="preview")
|
||||
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)
|
||||
@@ -336,13 +248,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.device_monitor.get_data(
|
||||
dev.eiger, count=1
|
||||
) # Get last image from Redis monitor 2D endpoint
|
||||
last_img = bec.connector.get_last(MessageEndpoints.device_preview("eiger", "preview"))[
|
||||
"data"
|
||||
].data
|
||||
assert np.allclose(img.get_data(), last_img)
|
||||
|
||||
# Now add a device with a preview signal
|
||||
img = widget.image(["eiger", "preview"])
|
||||
img = widget.image(device_name="eiger", device_entry="preview")
|
||||
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
s.wait()
|
||||
|
||||
@@ -537,30 +449,32 @@ 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.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)
|
||||
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)
|
||||
|
||||
@@ -3,9 +3,10 @@ import time
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
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
|
||||
@@ -131,3 +132,33 @@ 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"
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
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,
|
||||
busy_text: str = "Loading…",
|
||||
theme_update: bool = False,
|
||||
**kwargs,
|
||||
self, parent=None, *, start_busy: bool = False, theme_update: bool = False, **kwargs
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
theme_update=theme_update,
|
||||
start_busy=start_busy,
|
||||
busy_text=busy_text,
|
||||
**kwargs,
|
||||
)
|
||||
super().__init__(parent=parent, theme_update=theme_update, start_busy=start_busy, **kwargs)
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addWidget(QLabel("content", self))
|
||||
@@ -30,7 +20,7 @@ class _TestBusyWidget(BECWidget, QWidget):
|
||||
|
||||
@pytest.fixture
|
||||
def widget_busy(qtbot, mocked_client):
|
||||
w = _TestBusyWidget(client=mocked_client, start_busy=True, busy_text="Initializing…")
|
||||
w = _TestBusyWidget(client=mocked_client, start_busy=True)
|
||||
qtbot.addWidget(w)
|
||||
w.resize(320, 200)
|
||||
w.show()
|
||||
@@ -57,19 +47,46 @@ 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 None, "Overlay should be lazily created when idle"
|
||||
assert overlay is not None
|
||||
|
||||
widget_idle.set_busy(True, "Fetching data…")
|
||||
widget_idle.set_busy(True)
|
||||
overlay = getattr(widget_idle, "_busy_overlay")
|
||||
qtbot.waitUntil(lambda: overlay.isVisible())
|
||||
|
||||
lbl = getattr(overlay, "_label")
|
||||
assert lbl.text() == "Fetching data…"
|
||||
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
|
||||
|
||||
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())
|
||||
@@ -103,20 +120,6 @@ 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):
|
||||
@@ -131,15 +134,11 @@ 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, "Back to work…")
|
||||
widget_busy.set_busy(True)
|
||||
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())
|
||||
|
||||
@@ -34,9 +34,9 @@ class _TestDuplicatePlugin(RPCBase): ...
|
||||
|
||||
|
||||
mock_client_module_duplicate = SimpleNamespace()
|
||||
_TestDuplicatePlugin.__name__ = "DeviceComboBox"
|
||||
_TestDuplicatePlugin.__name__ = "Waveform"
|
||||
|
||||
mock_client_module_duplicate.DeviceComboBox = _TestDuplicatePlugin
|
||||
mock_client_module_duplicate.Waveform = _TestDuplicatePlugin
|
||||
|
||||
|
||||
@patch("bec_lib.logger.bec_logger")
|
||||
@@ -47,14 +47,14 @@ mock_client_module_duplicate.DeviceComboBox = _TestDuplicatePlugin
|
||||
@patch(
|
||||
"bec_widgets.utils.bec_plugin_helper.get_all_plugin_widgets",
|
||||
return_value=BECClassContainer(
|
||||
[BECClassInfo(name="DeviceComboBox", obj=_TestDuplicatePlugin, module="", file="")]
|
||||
[BECClassInfo(name="Waveform", obj=_TestDuplicatePlugin, module="", file="")]
|
||||
),
|
||||
)
|
||||
def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock):
|
||||
reload(client)
|
||||
assert (
|
||||
call(
|
||||
f"Detected duplicate widget DeviceComboBox in plugin repo file: {inspect.getfile(_TestDuplicatePlugin)} !"
|
||||
f"Detected duplicate widget Waveform in plugin repo file: {inspect.getfile(_TestDuplicatePlugin)} !"
|
||||
)
|
||||
in bec_logger.logger.warning.mock_calls
|
||||
)
|
||||
|
||||
@@ -3,13 +3,13 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from bec_widgets.cli.client import AdvancedDockArea
|
||||
from bec_widgets.cli.client import BECDockArea
|
||||
from bec_widgets.cli.client_utils import BECGuiClient, _start_plot_process
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_dock_area():
|
||||
dock_area = AdvancedDockArea(gui_id="test")
|
||||
dock_area = BECDockArea(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
|
||||
|
||||
@@ -82,6 +82,45 @@ 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")
|
||||
|
||||
@@ -59,3 +59,13 @@ 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
|
||||
)
|
||||
|
||||
68
tests/unit_tests/test_device_initialization_progress_bar.py
Normal file
68
tests/unit_tests/test_device_initialization_progress_bar.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# 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}"
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
@@ -142,3 +143,24 @@ 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"]
|
||||
|
||||
@@ -361,6 +361,75 @@ 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."""
|
||||
@@ -701,7 +770,7 @@ class TestOphydValidation:
|
||||
assert isinstance(validation_button.icon(), QtGui.QIcon)
|
||||
assert validation_button.styleSheet() == ""
|
||||
validation_button.setEnabled(False)
|
||||
assert validation_button.styleSheet() == validation_button.transparent_style
|
||||
assert validation_button.styleSheet() == ""
|
||||
|
||||
@pytest.fixture
|
||||
def validation_dialog(self, qtbot):
|
||||
@@ -1060,9 +1129,7 @@ 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, "_on_device_test_completed"
|
||||
) as mock_on_device_test_completed,
|
||||
mock.patch.object(ophyd_test.list_widget, "get_widget") as mock_get_widget,
|
||||
):
|
||||
ophyd_test.change_device_configs(
|
||||
[{"name": "device_2", "deviceClass": "TestClass"}],
|
||||
@@ -1070,12 +1137,7 @@ class TestOphydValidation:
|
||||
skip_validation=False,
|
||||
)
|
||||
mock_add_device_config.assert_called_once()
|
||||
mock_on_device_test_completed.assert_called_once_with(
|
||||
{"name": "device_2", "deviceClass": "TestClass"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
"Device already in session.",
|
||||
)
|
||||
mock_get_widget.assert_called_once_with("device_2")
|
||||
|
||||
def test_ophyd_test_adding_devices(self, ophyd_test: OphydValidation, qtbot):
|
||||
"""Test adding devices to OphydValidation widget."""
|
||||
|
||||
@@ -23,12 +23,14 @@ 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,
|
||||
@@ -56,9 +58,9 @@ class TestDeviceManagerViewDialogs:
|
||||
"""Test class for DeviceManagerView dialog interactions."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dm_view(self, qtbot):
|
||||
def mock_dm_view(self, qtbot, mocked_client):
|
||||
"""Fixture for DeviceManagerView."""
|
||||
widget = DeviceManagerDisplayWidget()
|
||||
widget = DeviceManagerDisplayWidget(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
@@ -305,7 +307,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, can not be empty with spaces. Please provide a valid name. {dialog._device_config_template.get_config_fields().get('name', '')!r} ",
|
||||
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}",
|
||||
)
|
||||
mock_create_dialog.assert_not_called()
|
||||
mock_create_validation.assert_not_called()
|
||||
@@ -592,6 +594,14 @@ 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."""
|
||||
@@ -603,6 +613,34 @@ 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
|
||||
):
|
||||
@@ -742,34 +780,59 @@ class TestDeviceManagerView:
|
||||
].action.action.triggered.emit()
|
||||
assert len(mock_change_configs.call_args[0][0]) == 1
|
||||
|
||||
def test_update_validation_icons_after_upload(
|
||||
self,
|
||||
device_manager_display_widget: DeviceManagerDisplayWidget,
|
||||
device_configs: list[dict[str, Any]],
|
||||
def test_handle_cancel_config_upload_failed(
|
||||
self, device_manager_display_widget: DeviceManagerDisplayWidget, qtbot
|
||||
):
|
||||
"""Test that validation icons are updated after uploading to Redis."""
|
||||
"""Test handling cancel during config upload failure."""
|
||||
dm_view = device_manager_display_widget
|
||||
|
||||
# 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
|
||||
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",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -210,3 +210,193 @@ 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"]
|
||||
|
||||
@@ -10,17 +10,14 @@ from qtpy.QtCore import QSettings, Qt, QTimer
|
||||
from qtpy.QtGui import QPixmap
|
||||
from qtpy.QtWidgets import QDialog, QMessageBox, QWidget
|
||||
|
||||
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 (
|
||||
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 (
|
||||
DockAreaWidget,
|
||||
DockSettingsDialog,
|
||||
)
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea, SaveProfileDialog
|
||||
from bec_widgets.widgets.containers.dock_area.profile_utils import (
|
||||
SETTINGS_KEYS,
|
||||
default_profile_path,
|
||||
get_profile_info,
|
||||
@@ -31,20 +28,17 @@ from bec_widgets.widgets.containers.advanced_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.advanced_dock_area.settings.dialogs import (
|
||||
from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
|
||||
PreviewPanel,
|
||||
RestoreProfileDialog,
|
||||
)
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.settings.workspace_manager import (
|
||||
WorkSpaceManager,
|
||||
)
|
||||
from bec_widgets.widgets.containers.dock_area.settings.workspace_manager import WorkSpaceManager
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
@@ -52,7 +46,7 @@ from .client_mocks import mocked_client
|
||||
@pytest.fixture
|
||||
def advanced_dock_area(qtbot, mocked_client):
|
||||
"""Create an AdvancedDockArea instance for testing."""
|
||||
widget = AdvancedDockArea(client=mocked_client)
|
||||
widget = BECDockArea(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
@@ -152,7 +146,7 @@ def workspace_manager_target():
|
||||
"""Mock delete_profile that performs actual file deletion."""
|
||||
from qtpy.QtWidgets import QMessageBox
|
||||
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
|
||||
from bec_widgets.widgets.containers.dock_area.profile_utils import (
|
||||
delete_profile_files,
|
||||
is_profile_read_only,
|
||||
)
|
||||
@@ -190,7 +184,7 @@ def basic_dock_area(qtbot, mocked_client):
|
||||
class _NamespaceProfiles:
|
||||
"""Helper that routes profile file helpers through a namespace."""
|
||||
|
||||
def __init__(self, widget: AdvancedDockArea):
|
||||
def __init__(self, widget: BECDockArea):
|
||||
self.namespace = widget.profile_namespace
|
||||
|
||||
def open_user(self, name: str):
|
||||
@@ -215,7 +209,7 @@ class _NamespaceProfiles:
|
||||
return is_quick_select(name, namespace=self.namespace)
|
||||
|
||||
|
||||
def profile_helper(widget: AdvancedDockArea) -> _NamespaceProfiles:
|
||||
def profile_helper(widget: BECDockArea) -> _NamespaceProfiles:
|
||||
"""Return a helper wired to the widget's profile namespace."""
|
||||
return _NamespaceProfiles(widget)
|
||||
|
||||
@@ -590,7 +584,7 @@ class TestAdvancedDockAreaInit:
|
||||
|
||||
def test_init(self, advanced_dock_area):
|
||||
assert advanced_dock_area is not None
|
||||
assert isinstance(advanced_dock_area, AdvancedDockArea)
|
||||
assert isinstance(advanced_dock_area, BECDockArea)
|
||||
assert advanced_dock_area.mode == "creator"
|
||||
assert hasattr(advanced_dock_area, "dock_manager")
|
||||
assert hasattr(advanced_dock_area, "toolbar")
|
||||
@@ -598,8 +592,8 @@ class TestAdvancedDockAreaInit:
|
||||
assert hasattr(advanced_dock_area, "state_manager")
|
||||
|
||||
def test_rpc_and_plugin_flags(self):
|
||||
assert AdvancedDockArea.RPC is True
|
||||
assert AdvancedDockArea.PLUGIN is False
|
||||
assert BECDockArea.RPC is True
|
||||
assert BECDockArea.PLUGIN is False
|
||||
|
||||
def test_user_access_list(self):
|
||||
expected_methods = [
|
||||
@@ -611,7 +605,7 @@ class TestAdvancedDockAreaInit:
|
||||
"delete_all",
|
||||
]
|
||||
for method in expected_methods:
|
||||
assert method in AdvancedDockArea.USER_ACCESS
|
||||
assert method in BECDockArea.USER_ACCESS
|
||||
|
||||
|
||||
class TestDockManagement:
|
||||
@@ -1421,21 +1415,21 @@ class TestAdvancedDockAreaRestoreAndDialogs:
|
||||
pix = QPixmap(8, 8)
|
||||
pix.fill(Qt.red)
|
||||
monkeypatch.setattr(
|
||||
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot",
|
||||
"bec_widgets.widgets.containers.dock_area.dock_area.load_user_profile_screenshot",
|
||||
lambda name, namespace=None: pix,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot",
|
||||
"bec_widgets.widgets.containers.dock_area.dock_area.load_default_profile_screenshot",
|
||||
lambda name, namespace=None: pix,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm",
|
||||
"bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm",
|
||||
lambda *args, **kwargs: True,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.restore_user_from_default"
|
||||
"bec_widgets.widgets.containers.dock_area.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,
|
||||
@@ -1457,20 +1451,20 @@ class TestAdvancedDockAreaRestoreAndDialogs:
|
||||
advanced_dock_area._current_profile_name = profile_name
|
||||
advanced_dock_area.isVisible = lambda: False
|
||||
monkeypatch.setattr(
|
||||
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot",
|
||||
"bec_widgets.widgets.containers.dock_area.dock_area.load_user_profile_screenshot",
|
||||
lambda name: QPixmap(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot",
|
||||
"bec_widgets.widgets.containers.dock_area.dock_area.load_default_profile_screenshot",
|
||||
lambda name: QPixmap(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm",
|
||||
"bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm",
|
||||
lambda *args, **kwargs: False,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.restore_user_from_default"
|
||||
"bec_widgets.widgets.containers.dock_area.dock_area.restore_user_from_default"
|
||||
) as mock_restore:
|
||||
advanced_dock_area.restore_user_profile_from_default()
|
||||
|
||||
@@ -1479,7 +1473,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.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm"
|
||||
"bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm"
|
||||
) as mock_confirm:
|
||||
advanced_dock_area.restore_user_profile_from_default()
|
||||
mock_confirm.assert_not_called()
|
||||
@@ -1723,8 +1717,7 @@ class TestWorkspaceProfileOperations:
|
||||
return False
|
||||
|
||||
with patch(
|
||||
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog",
|
||||
StubDialog,
|
||||
"bec_widgets.widgets.containers.dock_area.dock_area.SaveProfileDialog", StubDialog
|
||||
):
|
||||
advanced_dock_area.save_profile(profile_name, show_dialog=True)
|
||||
|
||||
@@ -1795,8 +1788,7 @@ class TestWorkspaceProfileOperations:
|
||||
return False
|
||||
|
||||
with patch(
|
||||
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog",
|
||||
StubDialog,
|
||||
"bec_widgets.widgets.containers.dock_area.dock_area.SaveProfileDialog", StubDialog
|
||||
):
|
||||
advanced_dock_area.save_profile(show_dialog=True)
|
||||
|
||||
@@ -1859,11 +1851,11 @@ class TestWorkspaceProfileOperations:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question",
|
||||
"bec_widgets.widgets.containers.dock_area.dock_area.QMessageBox.question",
|
||||
return_value=QMessageBox.Yes,
|
||||
) as mock_question,
|
||||
patch(
|
||||
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.information",
|
||||
"bec_widgets.widgets.containers.dock_area.dock_area.QMessageBox.information",
|
||||
return_value=None,
|
||||
) as mock_info,
|
||||
):
|
||||
@@ -1893,7 +1885,7 @@ class TestWorkspaceProfileOperations:
|
||||
mock_get_action.return_value.widget = mock_combo
|
||||
|
||||
with patch(
|
||||
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question"
|
||||
"bec_widgets.widgets.containers.dock_area.dock_area.QMessageBox.question"
|
||||
) as mock_question:
|
||||
mock_question.return_value = QMessageBox.Yes
|
||||
|
||||
@@ -45,3 +45,30 @@ 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
|
||||
|
||||
@@ -834,6 +834,24 @@ 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
Reference in New Issue
Block a user