1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-10 10:40:55 +02:00

Compare commits

..

26 Commits

Author SHA1 Message Date
semantic-release
9a2396ee9c 3.4.2
Automatically generated by python-semantic-release
2026-04-01 12:55:30 +00:00
2dab16b684 test: Add tests for admin access 2026-04-01 14:54:28 +02:00
e6c8cd0b1a fix: allow admin user to pass deployment group check 2026-04-01 14:54:28 +02:00
242f8933b2 fix(bec-atlas-admin-view): Fix atlas_url to bec-atlas-prod.psi.ch 2026-04-01 14:54:28 +02:00
semantic-release
83ac6bcd37 3.4.1
Automatically generated by python-semantic-release
2026-04-01 08:51:56 +00:00
90ecd8ea87 fix(ring): hook update hover to update method 2026-04-01 10:51:11 +02:00
copilot-swe-agent[bot]
6e5f6e7fbb test(ring_progress_bar): add unit tests for hover behavior 2026-04-01 10:51:11 +02:00
2f75aaea16 fix(ring): changed inheritance to BECWidget and added cleanup 2026-04-01 10:51:11 +02:00
677550931b fix(ring): minor general fixes 2026-04-01 10:51:11 +02:00
96b5179658 fix(ring_progress_bar): added hover mouse effect 2026-04-01 10:51:11 +02:00
e25b6604d1 fix(hover_widget): make it fancy + mouse tracking 2026-04-01 10:51:11 +02:00
semantic-release
f74c5a4516 3.4.0
Automatically generated by python-semantic-release
2026-03-26 11:25:40 +00:00
a2923752c2 fix(waveform): alignment panel indicators request autoscale if updated 2026-03-26 12:24:56 +01:00
a486c52058 feat(waveform): 1D alignment mode panel 2026-03-26 12:24:56 +01:00
31389a3dd0 fix(lmfit_dialog): compact layout size policy for better alignment panel UX 2026-03-26 12:24:56 +01:00
semantic-release
1676efc1ea 3.3.4
Automatically generated by python-semantic-release
2026-03-24 11:26:35 +00:00
copilot-swe-agent[bot]
05c38d9b82 fix(lmfit_dialog): fix fit_curve_id type annotation and remove_dap_data selection behavior
Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/bec-project/bec_widgets/sessions/97395c0e-0271-4cdf-b39f-f3117d21bfa3
2026-03-24 12:25:44 +01:00
f67b60ac98 fix(lmfit_dialog): dialog compact adjustment and cleanup of stale methods 2026-03-24 12:25:44 +01:00
5ec59d5dbb fix(lmfit_dialog): fix cpp object deleted 2026-03-24 12:25:44 +01:00
semantic-release
d46ffb59f0 3.3.3
Automatically generated by python-semantic-release
2026-03-23 18:24:36 +00:00
da400d20b6 fix(positioner_box): remove CompactPopupWidget inheritance 2026-03-23 19:23:47 +01:00
semantic-release
20f06d8659 3.3.2
Automatically generated by python-semantic-release
2026-03-22 19:13:32 +00:00
3d29a67c0b fix: typos 2026-03-22 20:12:46 +01:00
semantic-release
e7ef8a3891 3.3.1
Automatically generated by python-semantic-release
2026-03-20 14:01:06 +00:00
90222f3082 fix(dap_combobox): rewritten as proper combobox 2026-03-20 15:00:12 +01:00
79af15a88b fix(dap_combobox): added safeguard for no DAP models 2026-03-20 15:00:12 +01:00
62 changed files with 2576 additions and 1187 deletions

View File

@@ -1,6 +1,109 @@
# CHANGELOG
## v3.4.2 (2026-04-01)
### Bug Fixes
- Allow admin user to pass deployment group check
([`e6c8cd0`](https://github.com/bec-project/bec_widgets/commit/e6c8cd0b1a1162302071c93a2ac51880b3cf1b7d))
- **bec-atlas-admin-view**: Fix atlas_url to bec-atlas-prod.psi.ch
([`242f893`](https://github.com/bec-project/bec_widgets/commit/242f8933b246802f5f3a5b9df7de07901f151c82))
### Testing
- Add tests for admin access
([`2dab16b`](https://github.com/bec-project/bec_widgets/commit/2dab16b68415806f3f291657f394bb2d8654229d))
## v3.4.1 (2026-04-01)
### Bug Fixes
- **hover_widget**: Make it fancy + mouse tracking
([`e25b660`](https://github.com/bec-project/bec_widgets/commit/e25b6604d195804bbd6ea6aac395d44dc00d6155))
- **ring**: Changed inheritance to BECWidget and added cleanup
([`2f75aae`](https://github.com/bec-project/bec_widgets/commit/2f75aaea16a178e180e68d702cd1bdf85a768bcf))
- **ring**: Hook update hover to update method
([`90ecd8e`](https://github.com/bec-project/bec_widgets/commit/90ecd8ea87faf06c3f545e3f9241f403b733d7eb))
- **ring**: Minor general fixes
([`6775509`](https://github.com/bec-project/bec_widgets/commit/677550931b28fbf35fd55880bf6e001f7351b99b))
- **ring_progress_bar**: Added hover mouse effect
([`96b5179`](https://github.com/bec-project/bec_widgets/commit/96b5179658c41fb39df7a40f4d96e82092605791))
### Testing
- **ring_progress_bar**: Add unit tests for hover behavior
([`6e5f6e7`](https://github.com/bec-project/bec_widgets/commit/6e5f6e7fbb6f9680f6d026e105e6840d24c6591c))
## v3.4.0 (2026-03-26)
### Bug Fixes
- **lmfit_dialog**: Compact layout size policy for better alignment panel UX
([`31389a3`](https://github.com/bec-project/bec_widgets/commit/31389a3dd0c7b1c671acdf49ae50b08455f466a7))
- **waveform**: Alignment panel indicators request autoscale if updated
([`a292375`](https://github.com/bec-project/bec_widgets/commit/a2923752c27ad7b9749db3d309fe288747b85acb))
### Features
- **waveform**: 1d alignment mode panel
([`a486c52`](https://github.com/bec-project/bec_widgets/commit/a486c52058b4edbea00ad7bb018f1fa2822fb9c6))
## v3.3.4 (2026-03-24)
### Bug Fixes
- **lmfit_dialog**: Dialog compact adjustment and cleanup of stale methods
([`f67b60a`](https://github.com/bec-project/bec_widgets/commit/f67b60ac98cd87ed8391fee8545eb8064a068e67))
- **lmfit_dialog**: Fix cpp object deleted
([`5ec59d5`](https://github.com/bec-project/bec_widgets/commit/5ec59d5dbb75e3a9deb488b0affaf8cb704242b9))
- **lmfit_dialog**: Fix fit_curve_id type annotation and remove_dap_data selection behavior
([`05c38d9`](https://github.com/bec-project/bec_widgets/commit/05c38d9b82cc6dfaec8f5abf8e0ececa5d001524))
Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
Agent-Logs-Url:
https://github.com/bec-project/bec_widgets/sessions/97395c0e-0271-4cdf-b39f-f3117d21bfa3
## v3.3.3 (2026-03-23)
### Bug Fixes
- **positioner_box**: Remove CompactPopupWidget inheritance
([`da400d2`](https://github.com/bec-project/bec_widgets/commit/da400d20b672236241ce3a4480481ac6a5df1b2e))
## v3.3.2 (2026-03-22)
### Bug Fixes
- Typos
([`3d29a67`](https://github.com/bec-project/bec_widgets/commit/3d29a67c0b2175f2f29b8e5a7befce55f3d28fd3))
## v3.3.1 (2026-03-20)
### Bug Fixes
- **dap_combobox**: Added safeguard for no DAP models
([`79af15a`](https://github.com/bec-project/bec_widgets/commit/79af15a88b993cd5b6bf730796f995f20cf6f188))
- **dap_combobox**: Rewritten as proper combobox
([`90222f3`](https://github.com/bec-project/bec_widgets/commit/90222f30821f822eb24b0179401d4e43050e0156))
## v3.3.0 (2026-03-20)
### Bug Fixes

View File

@@ -263,7 +263,7 @@ class BECMainApp(BECMainWindow):
developer_view_step = self.guided_tour.register_widget(
widget=sidebar_developer_view,
title="Developer View",
text="Click here to access the Developer view to write scripts and makros.",
text="Click here to access the Developer view to write scripts and macros.",
)
tour_steps.append(developer_view_step)

View File

@@ -169,7 +169,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
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
# NOTE: We need here a separate 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)
@@ -607,8 +607,8 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
self.device_table_view._is_config_in_sync_with_redis()
)
validation_results = self.device_table_view.get_validation_results()
for config, config_status, connnection_status in validation_results.values():
if connnection_status == ConnectionStatus.CONNECTED.value:
for config, config_status, connection_status in validation_results.values():
if connection_status == ConnectionStatus.CONNECTED.value:
self.device_table_view.update_device_validation(
config, config_status, ConnectionStatus.CAN_CONNECT, ""
)

View File

@@ -21,7 +21,7 @@ logger = bec_logger.logger
class _WidgetsEnumType(str, enum.Enum):
"""Enum for the available widgets, to be generated programatically"""
"""Enum for the available widgets, to be generated programmatically"""
...
@@ -985,7 +985,7 @@ class Curve(RPCBase):
class DapComboBox(RPCBase):
"""The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC."""
"""Editable combobox listing the available DAP models."""
@rpc_call
def select_y_axis(self, y_axis: str):
@@ -1011,7 +1011,7 @@ class DapComboBox(RPCBase):
Slot to update the fit model.
Args:
default_device(str): Default device name.
fit_name(str): Fit model name.
"""

View File

@@ -94,7 +94,7 @@ logger = bec_logger.logger
if self._base:
self.content += """
class _WidgetsEnumType(str, enum.Enum):
\"\"\" Enum for the available widgets, to be generated programatically \"\"\"
\"\"\" Enum for the available widgets, to be generated programmatically \"\"\"
...
"""

View File

@@ -167,7 +167,7 @@ class BECConnector:
)
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
# If the gui_id is passed, it should be respected. However, this should be revisted since
# If the gui_id is passed, it should be respected. However, this should be revisited since
# the gui_id has to be unique, and may no longer be.
if gui_id:
self.config.gui_id = gui_id
@@ -399,7 +399,7 @@ class BECConnector:
"""
self.config = config
# FIXME some thoughts are required to decide how thhis should work with rpc registry
# FIXME some thoughts are required to decide how this should work with rpc registry
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
"""
Apply the configuration to the widget.
@@ -417,7 +417,7 @@ class BECConnector:
else:
self.gui_id = self.config.gui_id
# FIXME some thoughts are required to decide how thhis should work with rpc registry
# FIXME some thoughts are required to decide how this should work with rpc registry
def load_config(self, path: str | None = None, gui: bool = False):
"""
Load the configuration of the widget from YAML.

View File

@@ -43,7 +43,7 @@ class WidgetContainerUtils:
if list_of_names is None:
list_of_names = []
ii = 0
while ii < 1000: # 1000 is arbritrary!
while ii < 1000: # 1000 is arbitrary!
name_candidate = f"{name}_{ii}"
if name_candidate not in list_of_names:
return name_candidate

View File

@@ -71,7 +71,7 @@ class FormItemSpec(BaseModel):
"""
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
to store most annotation info, since one of the main purposes is to store data for
forms genrated from pydantic models, but can also be composed from other sources or by hand.
forms generated from pydantic models, but can also be composed from other sources or by hand.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -192,7 +192,7 @@ class DynamicFormItem(QWidget):
@abstractmethod
def _add_main_widget(self) -> None:
self._main_widget: QWidget
"""Add the main data entry widget to self._main_widget and appply any
"""Add the main data entry widget to self._main_widget and apply any
constraints from the field info"""
@SafeSlot()

View File

@@ -15,7 +15,7 @@ class Kind(IFBase):
"""
This is used in the .kind attribute of all OphydObj (Signals, Devices).
A Device examines its components' .kind atttribute to decide whether to
A Device examines its components' .kind attribute to decide whether to
traverse it in read(), read_configuration(), or neither. Additionally, if
decides whether to include its name in `hints['fields']`.
"""

View File

@@ -156,7 +156,7 @@ class RPCServer:
if method == "raise" and hasattr(
obj, "setWindowState"
): # special case for raising windows, should work even if minimized
# this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
# this is a special case for raising windows for gnome on Red Hat (RHEL) 9 systems where changing focus is suppressed by default
# The procedure is as follows:
# 1. Get the current window state to check if the window is minimized and remove minimized flag
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
@@ -442,5 +442,5 @@ class RPCServer:
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
logger.info("Succeded in shutting down CLI server")
logger.info("Succeeded in shutting down CLI server")
self.client.shutdown()

View File

@@ -1,27 +1,83 @@
import sys
from qtpy import QtGui, QtWidgets
from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QProgressBar,
QVBoxLayout,
QWidget,
)
class WidgetTooltip(QWidget):
"""Frameless, always-on-top window that behaves like a tooltip."""
def __init__(self, content: QWidget) -> None:
super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_ShowWithoutActivating)
super().__init__(
None,
Qt.WindowType.ToolTip
| Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint,
)
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setMouseTracking(True)
self.content = content
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
layout.addWidget(self.content)
layout.setContentsMargins(14, 14, 14, 14)
self._card = QFrame(self)
self._card.setObjectName("WidgetTooltipCard")
card_layout = QVBoxLayout(self._card)
card_layout.setContentsMargins(12, 10, 12, 10)
card_layout.addWidget(self.content)
shadow = QtWidgets.QGraphicsDropShadowEffect(self._card)
shadow.setBlurRadius(18)
shadow.setOffset(0, 2)
shadow.setColor(QtGui.QColor(0, 0, 0, 140))
self._card.setGraphicsEffect(shadow)
layout.addWidget(self._card)
self.apply_theme()
self.adjustSize()
def leaveEvent(self, _event) -> None:
self.hide()
def apply_theme(self) -> None:
palette = QApplication.palette()
base = palette.color(QtGui.QPalette.ColorRole.Base)
text = palette.color(QtGui.QPalette.ColorRole.Text)
border = palette.color(QtGui.QPalette.ColorRole.Mid)
background = QtGui.QColor(base)
background.setAlpha(242)
self._card.setStyleSheet(f"""
QFrame#WidgetTooltipCard {{
background: {background.name(QtGui.QColor.NameFormat.HexArgb)};
border: 1px solid {border.name()};
border-radius: 12px;
}}
QFrame#WidgetTooltipCard QLabel {{
color: {text.name()};
background: transparent;
}}
""")
def show_above(self, global_pos: QPoint, offset: int = 8) -> None:
"""
Show the tooltip above a global position, adjusting to stay within screen bounds.
Args:
global_pos(QPoint): The global position to show above.
offset(int, optional): The vertical offset from the global position. Defaults to 8 pixels.
"""
self.apply_theme()
self.adjustSize()
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
screen_geo = screen.availableGeometry()
@@ -30,11 +86,43 @@ class WidgetTooltip(QWidget):
x = global_pos.x() - geom.width() // 2
y = global_pos.y() - geom.height() - offset
self._navigate_screen_coordinates(screen_geo, geom, x, y)
def show_near(self, global_pos: QPoint, offset: QPoint | None = None) -> None:
"""
Show the tooltip near a global position, adjusting to stay within screen bounds.
By default, it will try to show below and to the right of the position,
but if that would cause it to go off-screen, it will flip to the other side.
Args:
global_pos(QPoint): The global position to show near.
offset(QPoint, optional): The offset from the global position. Defaults to QPoint(12, 16).
"""
self.apply_theme()
self.adjustSize()
offset = offset or QPoint(12, 16)
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
screen_geo = screen.availableGeometry()
geom = self.geometry()
x = global_pos.x() + offset.x()
y = global_pos.y() + offset.y()
if x + geom.width() > screen_geo.right():
x = global_pos.x() - geom.width() - abs(offset.x())
if y + geom.height() > screen_geo.bottom():
y = global_pos.y() - geom.height() - abs(offset.y())
self._navigate_screen_coordinates(screen_geo, geom, x, y)
def _navigate_screen_coordinates(self, screen_geo, geom, x, y):
x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width()))
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
self.move(x, y)
self.show()
self.raise_()
class HoverWidget(QWidget):

View File

@@ -1,3 +0,0 @@
from .positioner_box_base import PositionerBoxBase
__ALL__ = ["PositionerBoxBase"]

View File

@@ -14,9 +14,9 @@ from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import apply_theme, get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
DeviceUpdateUIComponents,
PositionerBoxBase,
)
logger = bec_logger.logger
@@ -63,10 +63,10 @@ class PositionerBox(PositionerBoxBase):
self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
self.addWidget(self.ui)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter)
self.main_layout.addWidget(self.ui)
self.main_layout.setSpacing(0)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter)
ui_min_size = self.ui.minimumSize()
ui_min_hint = self.ui.minimumSizeHint()
self.setMinimumSize(
@@ -115,8 +115,6 @@ class PositionerBox(PositionerBoxBase):
return
old_device = self._device
self._device = value
if not self.label:
self.label = value
self.device_changed.emit(old_device, value)
@SafeProperty(bool)

View File

@@ -15,9 +15,9 @@ from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
DeviceUpdateUIComponents,
PositionerBoxBase,
)
logger = bec_logger.logger
@@ -96,9 +96,9 @@ class PositionerBox2D(PositionerBoxBase):
def connect_ui(self):
"""Connect the UI components to signals, data, or routines"""
self.addWidget(self.ui)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.addWidget(self.ui)
self.main_layout.setSpacing(0)
self.main_layout.setContentsMargins(0, 0, 0, 0)
def _init_ui(val: QDoubleValidator, device_id: DeviceId):
ui = self._device_ui_components_hv(device_id)
@@ -200,7 +200,6 @@ class PositionerBox2D(PositionerBoxBase):
return
old_device = self._device_hor
self._device_hor = value
self.label = f"{self._device_hor}, {self._device_ver}"
self.device_changed_hor.emit(old_device, value)
self._init_device(self.device_hor, self.position_update_hor.emit, self.update_limits_hor)
@@ -220,7 +219,6 @@ class PositionerBox2D(PositionerBoxBase):
return
old_device = self._device_ver
self._device_ver = value
self.label = f"{self._device_hor}, {self._device_ver}"
self.device_changed_ver.emit(old_device, value)
self._init_device(self.device_ver, self.position_update_ver.emit, self.update_limits_ver)

View File

@@ -14,10 +14,10 @@ from qtpy.QtWidgets import (
QLineEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
PositionIndicator,
)
@@ -43,7 +43,7 @@ class DeviceUpdateUIComponents(TypedDict):
units: QLabel
class PositionerBoxBase(BECWidget, CompactPopupWidget):
class PositionerBoxBase(BECWidget, QWidget):
"""Contains some core logic for positioner box widgets"""
current_path = ""
@@ -57,7 +57,10 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
parent: The parent widget.
device (Positioner): The device to control.
"""
super().__init__(parent=parent, layout=QVBoxLayout, **kwargs)
super().__init__(parent=parent, **kwargs)
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self._dialog = None
self.get_bec_shortcuts()
@@ -173,11 +176,9 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
if is_moving:
spinner.start()
spinner.setToolTip("Device is moving")
self.set_global_state("warning")
else:
spinner.stop()
spinner.setToolTip("Device is idle")
self.set_global_state("success")
else:
spinner.setVisible(False)
@@ -196,9 +197,8 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
position_indicator.set_value(pos)
def _update_limits_ui(
self, limits: tuple[float, float], position_indicator, setpoint_validator
):
@staticmethod
def _update_limits_ui(limits: tuple[float, float], position_indicator, setpoint_validator):
if limits is not None and limits[0] != limits[1]:
position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
setpoint_validator.setRange(limits[0], limits[1])
@@ -223,8 +223,9 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device))
def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None:
"""Toogle enable/disable on available buttons
@staticmethod
def _toggle_enable_buttons(ui: DeviceUpdateUIComponents, enable: bool) -> None:
"""Toggle enable/disable on available buttons
Args:
enable (bool): Enable buttons

View File

@@ -1,6 +1,8 @@
import os
from bec_lib.device import Positioner
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QSizePolicy
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
@@ -22,7 +24,82 @@ class PositionerControlLine(PositionerBox):
device (Positioner): The device to control.
"""
self.current_path = os.path.dirname(__file__)
self._indicator_switch_width = 0
self._horizontal_indicator_width = 0
self._vertical_indicator_width = 15
self._indicator_thickness = 10
self._indicator_is_horizontal = False
self._line_height = self.dimensions[0]
super().__init__(parent=parent, device=device, *args, **kwargs)
self._configure_line_layout()
self._update_indicator_orientation()
def _configure_line_layout(self):
device_box = self.ui.device_box
indicator = self.ui.position_indicator
self.main_layout.setAlignment(Qt.AlignmentFlag(0))
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.ui.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
device_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._line_height = max(
self.dimensions[0],
self.ui.minimumSizeHint().height(),
self.ui.sizeHint().height(),
device_box.minimumSizeHint().height(),
device_box.sizeHint().height(),
)
device_box.setFixedHeight(self._line_height)
device_box.setMinimumWidth(self.dimensions[1])
device_box.setMaximumWidth(16777215)
self.setFixedHeight(self._line_height)
self.setMinimumWidth(self.dimensions[1])
self.ui.verticalLayout.setContentsMargins(0, 0, 0, 0)
self.ui.verticalLayout.setSpacing(0)
self.ui.readback.setMaximumWidth(16777215)
self.ui.setpoint.setMaximumWidth(16777215)
self.ui.step_size.setMaximumWidth(16777215)
indicator_hint = indicator.minimumSizeHint()
step_hint = self.ui.step_size.sizeHint()
self._indicator_thickness = max(indicator_hint.height(), 10)
self._vertical_indicator_width = max(indicator.minimumWidth(), 15)
self._horizontal_indicator_width = max(90, step_hint.width())
base_width = max(device_box.minimumSizeHint().width(), self.dimensions[1])
self._indicator_switch_width = (
base_width - self._vertical_indicator_width + self._horizontal_indicator_width
)
def resizeEvent(self, event):
super().resizeEvent(event)
self._update_indicator_orientation()
def _update_indicator_orientation(self):
if not hasattr(self, "ui"):
return
indicator = self.ui.position_indicator
available_width = self.ui.device_box.width() or self.width() or self.dimensions[1]
should_use_horizontal = available_width >= self._indicator_switch_width
if should_use_horizontal == self._indicator_is_horizontal:
return
self._indicator_is_horizontal = should_use_horizontal
indicator.vertical = not should_use_horizontal
if should_use_horizontal:
indicator.setMinimumSize(self._horizontal_indicator_width, self._indicator_thickness)
indicator.setMaximumHeight(self._indicator_thickness)
indicator.setMaximumWidth(16777215)
indicator.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed)
else:
indicator.setMinimumSize(self._vertical_indicator_width, self._indicator_thickness)
indicator.setMaximumSize(self._vertical_indicator_width, 16777215)
indicator.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
indicator.updateGeometry()
if __name__ == "__main__": # pragma: no cover

View File

@@ -2,12 +2,18 @@
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>612</width>
<height>91</height>
<width>592</width>
<height>76</height>
</rect>
</property>
<property name="minimumSize">
@@ -26,8 +32,29 @@
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QGroupBox" name="device_box">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Device Name</string>
</property>
@@ -227,12 +254,12 @@
<customwidgets>
<customwidget>
<class>PositionIndicator</class>
<extends>QWidget</extends>
<extends></extends>
<header>position_indicator</header>
</customwidget>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>
<extends></extends>
<header>spinner_widget</header>
</customwidget>
</customwidgets>

View File

@@ -27,30 +27,13 @@ class PositionerGroupBox(QGroupBox):
self.layout().setContentsMargins(0, 0, 0, 0)
self.layout().setSpacing(0)
self.widget = PositionerBox(self, dev_name)
self.widget.compact_view = True
self.widget.expand_popup = False
self.layout().addWidget(self.widget)
self.widget.position_update.connect(self._on_position_update)
self.widget.expand.connect(self._on_expand)
self.setTitle(self.device_name)
self.widget.force_update_readback()
def _on_expand(self, expand):
if expand:
self.setTitle("")
self.setFlat(True)
else:
self.setTitle(self.device_name)
self.setFlat(False)
def _on_position_update(self, pos: float):
self.position_update.emit(pos)
precision = getattr(self.widget.dev[self.widget.device], "precision", 8)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = int(8)
self.widget.label = f"{pos:.{precision}f}"
def close(self):
self.widget.close()

View File

@@ -13,7 +13,7 @@ if TYPE_CHECKING:
from .available_device_group import AvailableDeviceGroup
class _DeviceListWiget(QListWidget):
class _DeviceListWidget(QListWidget):
def _item_iter(self):
return (self.item(i) for i in range(self.count()))
@@ -44,7 +44,7 @@ class Ui_AvailableDeviceGroup(object):
self.n_included.setObjectName("n_included")
title_layout.addWidget(self.n_included)
self.device_list = _DeviceListWiget(AvailableDeviceGroup)
self.device_list = _DeviceListWidget(AvailableDeviceGroup)
self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
self.device_list.setObjectName("device_list")
self.device_list.setFrameStyle(0)

View File

@@ -34,13 +34,13 @@ class HashModel(str, Enum):
class DeviceResourceBackend(Protocol):
@property
def tag_groups(self) -> dict[str, set[HashableDevice]]:
"""A dictionary of all availble devices separated by tag groups. The same device may
"""A dictionary of all available devices separated by tag groups. The same device may
appear more than once (in different groups)."""
...
@property
def all_devices(self) -> set[HashableDevice]:
"""A set of all availble devices. The same device may not appear more than once."""
"""A set of all available devices. The same device may not appear more than once."""
...
@property

View File

@@ -347,14 +347,14 @@ class ScanGroupBox(QGroupBox):
def get_parameters(self, device_object: bool = True):
"""
Returns the parameters from the widgets in the scan control layout formated to run scan from BEC.
Returns the parameters from the widgets in the scan control layout formatted to run scan from BEC.
"""
if self.box_type == "args":
return self._get_arg_parameterts(device_object=device_object)
return self._get_arg_parameters(device_object=device_object)
elif self.box_type == "kwargs":
return self._get_kwarg_parameters(device_object=device_object)
def _get_arg_parameterts(self, device_object: bool = True):
def _get_arg_parameters(self, device_object: bool = True):
args = []
for i in range(1, self.layout.rowCount()):
for j in range(self.layout.columnCount()):

View File

@@ -2,22 +2,19 @@
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
from qtpy.QtWidgets import QComboBox
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger
class DapComboBox(BECWidget, QWidget):
class DapComboBox(BECWidget, QComboBox):
"""
The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC.
Editable combobox listing the available DAP models.
Args:
parent: Parent widget.
client: BEC client object.
gui_id: GUI ID.
default: Default device name.
The widget behaves as a plain QComboBox and keeps ``fit_model_combobox`` as an alias to itself
for backwards compatibility with older call sites.
"""
ICON_NAME = "data_exploration"
@@ -45,19 +42,20 @@ class DapComboBox(BECWidget, QWidget):
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
self.layout = QVBoxLayout(self)
self.fit_model_combobox = QComboBox(self)
self.layout.addWidget(self.fit_model_combobox)
self.layout.setContentsMargins(0, 0, 0, 0)
self._available_models = None
self.fit_model_combobox = self # Just for backwards compatibility with older call sites, the widget itself is the combobox
self._available_models: list[str] = []
self._x_axis = None
self._y_axis = None
self.populate_fit_model_combobox()
self.fit_model_combobox.currentTextChanged.connect(self._update_current_fit)
# Set default fit model
self.select_default_fit(default_fit)
self._is_valid_input = False
def select_default_fit(self, default_fit: str | None):
self.setEditable(True)
self.populate_fit_model_combobox()
self.currentTextChanged.connect(self._on_text_changed)
self.select_default_fit(default_fit)
self.check_validity(self.currentText())
def select_default_fit(self, default_fit: str | None = "GaussianModel"):
"""Set the default fit model.
Args:
@@ -65,8 +63,8 @@ class DapComboBox(BECWidget, QWidget):
"""
if self._validate_dap_model(default_fit):
self.select_fit_model(default_fit)
else:
self.select_fit_model("GaussianModel")
elif self.available_models:
self.select_fit_model(self.available_models[0])
@property
def available_models(self):
@@ -114,12 +112,40 @@ class DapComboBox(BECWidget, QWidget):
self._y_axis = y_axis
self.y_axis_updated.emit(y_axis)
def _update_current_fit(self, fit_name: str):
"""Update the current fit."""
@Slot(str)
def _on_text_changed(self, fit_name: str):
"""
Validate and emit updates for the current text.
Args:
fit_name(str): The current text in the combobox, representing the selected fit model.
"""
self.check_validity(fit_name)
if not self._is_valid_input:
return
self.fit_model_updated.emit(fit_name)
if self.x_axis is not None and self.y_axis is not None:
self.new_dap_config.emit(self._x_axis, self._y_axis, fit_name)
@Slot(str)
def check_validity(self, fit_name: str):
"""
Highlight invalid manual entries similarly to DeviceComboBox.
Args:
fit_name(str): The current text in the combobox, representing the selected fit model.
"""
if self._validate_dap_model(fit_name):
self._is_valid_input = True
self.setStyleSheet("border: 1px solid transparent;")
else:
self._is_valid_input = False
if self.isEnabled():
self.setStyleSheet("border: 1px solid red;")
else:
self.setStyleSheet("border: 1px solid transparent;")
@Slot(str)
def select_x_axis(self, x_axis: str):
"""Slot to update the x axis.
@@ -128,7 +154,7 @@ class DapComboBox(BECWidget, QWidget):
x_axis(str): X axis.
"""
self.x_axis = x_axis
self._update_current_fit(self.fit_model_combobox.currentText())
self._on_text_changed(self.currentText())
@Slot(str)
def select_y_axis(self, y_axis: str):
@@ -138,25 +164,26 @@ class DapComboBox(BECWidget, QWidget):
y_axis(str): Y axis.
"""
self.y_axis = y_axis
self._update_current_fit(self.fit_model_combobox.currentText())
self._on_text_changed(self.currentText())
@Slot(str)
def select_fit_model(self, fit_name: str | None):
"""Slot to update the fit model.
Args:
default_device(str): Default device name.
fit_name(str): Fit model name.
"""
if not self._validate_dap_model(fit_name):
raise ValueError(f"Fit {fit_name} is not valid.")
self.fit_model_combobox.setCurrentText(fit_name)
self.setCurrentText(fit_name)
def populate_fit_model_combobox(self):
"""Populate the fit_model_combobox with the devices."""
# pylint: disable=protected-access
self.available_models = [model for model in self.client.dap._available_dap_plugins.keys()]
self.fit_model_combobox.clear()
self.fit_model_combobox.addItems(self.available_models)
available_plugins = getattr(getattr(self.client, "dap", None), "_available_dap_plugins", {})
self.available_models = [model for model in available_plugins.keys()]
self.clear()
self.addItems(self.available_models)
def _validate_dap_model(self, model: str | None) -> bool:
"""Validate the DAP model.
@@ -166,23 +193,23 @@ class DapComboBox(BECWidget, QWidget):
"""
if model is None:
return False
if model not in self.available_models:
return False
return True
return model in self.available_models
@property
def is_valid_input(self) -> bool:
"""Whether the current text matches an available DAP model."""
return self._is_valid_input
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
import sys
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import apply_theme
app = QApplication([])
app = QApplication(sys.argv)
apply_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
layout.addWidget(DapComboBox())
widget.show()
app.exec_()
dialog = DapComboBox()
dialog.show()
sys.exit(app.exec_())

View File

@@ -1,8 +1,9 @@
import os
import shiboken6
from bec_lib.logger import bec_logger
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QPushButton, QTreeWidgetItem, QVBoxLayout, QWidget
from qtpy.QtWidgets import QPushButton, QSizePolicy, QTreeWidgetItem, QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
@@ -34,7 +35,7 @@ class LMFitDialog(BECWidget, QWidget):
**kwargs,
):
"""
Initialises the LMFitDialog widget.
Initializes the LMFitDialog widget.
Args:
parent (QWidget): The parent widget.
@@ -68,6 +69,27 @@ class LMFitDialog(BECWidget, QWidget):
self._hide_curve_selection = False
self._hide_summary = False
self._hide_parameters = False
self._configure_embedded_size_policy()
def _configure_embedded_size_policy(self):
"""Allow the compact dialog to shrink more gracefully in embedded layouts."""
if self._ui_file != "lmfit_dialog_compact.ui":
return
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
self.ui.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
for group in (
self.ui.group_curve_selection,
self.ui.group_summary,
self.ui.group_parameters,
):
group.setMinimumHeight(0)
group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
for view in (self.ui.curve_list, self.ui.summary_tree, self.ui.param_tree):
view.setMinimumHeight(0)
view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Ignored)
@property
def enable_actions(self) -> bool:
@@ -77,8 +99,14 @@ class LMFitDialog(BECWidget, QWidget):
@enable_actions.setter
def enable_actions(self, enable: bool):
self._enable_actions = enable
for button in self.action_buttons.values():
valid_buttons = {}
for name, button in self.action_buttons.items():
# just to be sure we have a valid c++ object
if button is None or not shiboken6.isValid(button):
continue
button.setEnabled(enable)
valid_buttons[name] = button
self.action_buttons = valid_buttons
@SafeProperty(list)
def active_action_list(self) -> list[str]:
@@ -89,16 +117,6 @@ class LMFitDialog(BECWidget, QWidget):
def active_action_list(self, actions: list[str]):
self._active_actions = actions
# This SafeSlot needed?
@SafeSlot(bool)
def set_actions_enabled(self, enable: bool) -> bool:
"""SafeSlot to enable the move to buttons.
Args:
enable (bool): Whether to enable the action buttons.
"""
self.enable_actions = enable
@SafeProperty(bool)
def always_show_latest(self):
"""SafeProperty to indicate if always the latest DAP update is displayed."""
@@ -154,19 +172,21 @@ class LMFitDialog(BECWidget, QWidget):
self.ui.group_parameters.setVisible(not show)
@property
def fit_curve_id(self) -> str:
def fit_curve_id(self) -> str | None:
"""SafeProperty for the currently displayed fit curve_id."""
return self._fit_curve_id
@fit_curve_id.setter
def fit_curve_id(self, curve_id: str):
def fit_curve_id(self, curve_id: str | None):
"""Setter for the currently displayed fit curve_id.
Args:
fit_curve_id (str): The curve_id of the fit curve to be displayed.
curve_id (str | None): The curve_id of the fit curve to be displayed,
or None to clear the selection.
"""
self._fit_curve_id = curve_id
self.selected_fit.emit(curve_id)
if curve_id is not None:
self.selected_fit.emit(curve_id)
@SafeSlot(str)
def remove_dap_data(self, curve_id: str):
@@ -176,6 +196,15 @@ class LMFitDialog(BECWidget, QWidget):
curve_id (str): The curve_id of the DAP data to be removed.
"""
self.summary_data.pop(curve_id, None)
if self.fit_curve_id == curve_id:
self.action_buttons = {}
self.ui.summary_tree.clear()
self.ui.param_tree.clear()
remaining = list(self.summary_data.keys())
if remaining:
self.fit_curve_id = remaining[0]
else:
self._fit_curve_id = None
self.refresh_curve_list()
@SafeSlot(str)
@@ -251,6 +280,7 @@ class LMFitDialog(BECWidget, QWidget):
params (list): List of LMFit parameters for the fit curve.
"""
self._move_buttons = []
self.action_buttons = {}
self.ui.param_tree.clear()
for param in params:
param_name = param[0]
@@ -269,9 +299,9 @@ class LMFitDialog(BECWidget, QWidget):
if param_name in self.active_action_list: # pylint: disable=unsupported-membership-test
# Create a push button to move the motor to a specific position
widget = QWidget()
button = QPushButton(f"Move to {param_name}")
button = QPushButton("Move")
button.clicked.connect(self._create_move_action(param_name, param[1]))
if self.enable_actions is True:
if self.enable_actions:
button.setEnabled(True)
else:
button.setEnabled(False)

View File

@@ -14,6 +14,18 @@
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item row="0" column="0">
<widget class="QSplitter" name="splitter_2">
<property name="sizePolicy">
@@ -22,15 +34,6 @@
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="frameShape">
<enum>QFrame::Shape::VLine</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Plain</enum>
</property>
<property name="lineWidth">
<number>1</number>
</property>
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
@@ -41,6 +44,12 @@
<bool>true</bool>
</property>
<widget class="QGroupBox" name="group_curve_selection">
<property name="minimumSize">
<size>
<width>120</width>
<height>0</height>
</size>
</property>
<property name="title">
<string>Select Curve</string>
</property>
@@ -58,18 +67,36 @@
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
<enum>Qt::Orientation::Horizontal</enum>
</property>
<widget class="QGroupBox" name="group_summary">
<property name="minimumSize">
<size>
<width>180</width>
<height>0</height>
</size>
</property>
<property name="title">
<string>Fit Summary</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QTreeWidget" name="summary_tree">
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="indentation">
<number>0</number>
</property>
<property name="rootIsDecorated">
<bool>false</bool>
</property>
<property name="uniformRowHeights">
<bool>false</bool>
</property>
<attribute name="headerDefaultSectionSize">
<number>90</number>
</attribute>
<column>
<property name="text">
<string>Property</string>
@@ -85,12 +112,33 @@
</layout>
</widget>
<widget class="QGroupBox" name="group_parameters">
<property name="minimumSize">
<size>
<width>240</width>
<height>0</height>
</size>
</property>
<property name="title">
<string>Parameter Details</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QTreeWidget" name="param_tree">
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="indentation">
<number>0</number>
</property>
<property name="rootIsDecorated">
<bool>false</bool>
</property>
<property name="columnCount">
<number>4</number>
</property>
<attribute name="headerDefaultSectionSize">
<number>80</number>
</attribute>
<column>
<property name="text">
<string>Parameter</string>
@@ -106,6 +154,11 @@
<string>Std</string>
</property>
</column>
<column>
<property name="text">
<string>Action</string>
</property>
</column>
</widget>
</item>
</layout>

View File

@@ -95,6 +95,12 @@
<height>0</height>
</size>
</property>
<property name="indentation">
<number>0</number>
</property>
<property name="rootIsDecorated">
<bool>false</bool>
</property>
<property name="uniformRowHeights">
<bool>false</bool>
</property>
@@ -147,6 +153,12 @@
<width>0</width>
<height>0</height>
</size>
</property>
<property name="indentation">
<number>0</number>
</property>
<property name="rootIsDecorated">
<bool>false</bool>
</property>
<property name="columnCount">
<number>4</number>

View File

@@ -0,0 +1,345 @@
from __future__ import annotations
from dataclasses import dataclass
import pyqtgraph as pg
from qtpy.QtCore import QObject, Qt, Signal
from qtpy.QtGui import QColor
from bec_widgets.utils.colors import get_accent_colors, get_theme_name
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
@dataclass(slots=True)
class AlignmentContext:
"""Alignment state produced by `Waveform` and consumed by the controller.
Attributes:
visible: Whether alignment mode is currently visible.
positioner_name: Name of the resolved x-axis positioner, if available.
precision: Decimal precision to use for readback and target labels.
limits: Optional positioner limits for the draggable target line.
readback: Current cached positioner readback value.
has_dap_curves: Whether the waveform currently contains any DAP curves.
force_readback: Whether the embedded positioner should refresh its readback immediately.
"""
visible: bool
positioner_name: str | None
precision: int = 3
limits: tuple[float, float] | None = None
readback: float | None = None
has_dap_curves: bool = False
force_readback: bool = False
class WaveformAlignmentController(QObject):
"""Own the alignment plot overlays and synchronize them with the alignment panel."""
move_absolute_requested = Signal(float)
autoscale_requested = Signal()
def __init__(self, plot_item: pg.PlotItem, panel: WaveformAlignmentPanel, parent=None):
super().__init__(parent=parent)
self._plot_item = plot_item
self._panel = panel
self._visible = False
self._positioner_name: str | None = None
self._precision = 3
self._limits: tuple[float, float] | None = None
self._readback: float | None = None
self._marker_line: pg.InfiniteLine | None = None
self._target_line: pg.InfiniteLine | None = None
self._panel.position_readback_changed.connect(self.update_position)
self._panel.target_toggled.connect(self._on_target_toggled)
self._panel.target_move_requested.connect(self._on_target_move_requested)
self._panel.fit_selection_changed.connect(self._on_fit_selection_changed)
self._panel.fit_center_requested.connect(self._on_fit_center_requested)
@property
def marker_line(self) -> pg.InfiniteLine | None:
"""Return the current-position indicator line, if it exists."""
return self._marker_line
@property
def target_line(self) -> pg.InfiniteLine | None:
"""Return the draggable target indicator line, if it exists."""
return self._target_line
def update_context(self, context: AlignmentContext):
"""Apply waveform-owned alignment context to the panel and plot overlays.
Args:
context: Snapshot of the current alignment-relevant waveform/device state.
"""
previous_name = self._positioner_name
self._visible = context.visible
self._positioner_name = context.positioner_name
self._precision = context.precision
self._limits = context.limits
self._readback = context.readback
self._panel.set_positioner_device(context.positioner_name)
self._panel.set_positioner_enabled(context.visible and context.positioner_name is not None)
self._panel.set_status_message(self._status_message_for_context(context))
if context.positioner_name is None or not context.visible:
self.clear()
self._refresh_fit_actions()
self._refresh_target_controls()
return
if previous_name != context.positioner_name:
self._clear_marker()
if self._panel.target_active:
self._clear_target_line()
if context.readback is not None:
self.update_position(context.readback)
if self._panel.target_active:
if previous_name != context.positioner_name or self._target_line is None:
self._show_target_line()
else:
self._refresh_target_line_metadata()
self._on_target_line_changed()
if context.force_readback or previous_name != context.positioner_name:
self._panel.force_positioner_readback()
self._refresh_fit_actions()
self._refresh_target_controls()
@SafeSlot(float)
def update_position(self, position: float):
"""Update the live position marker from a positioner readback value.
Args:
position: Current absolute position of the active alignment positioner.
"""
self._readback = float(position)
if not self._visible or self._positioner_name is None:
self._clear_marker()
return
self._ensure_marker()
self._marker_line.setValue(self._readback)
self._marker_line.label.setText(
f"{self._positioner_name}: {self._readback:.{self._precision}f}"
)
self.autoscale_requested.emit()
@SafeSlot(dict, dict)
def update_dap_summary(self, data: dict, metadata: dict):
"""Forward DAP summary updates into the alignment fit panel.
Args:
data: DAP fit summary payload.
metadata: Metadata describing the emitting DAP curve.
"""
self._panel.update_dap_summary(data, metadata)
self._refresh_fit_actions()
@SafeSlot(str)
def remove_dap_curve(self, curve_id: str):
"""Remove a deleted DAP curve from the alignment fit selection state.
Args:
curve_id: Label of the DAP curve that was removed from the waveform.
"""
self._panel.remove_dap_curve(curve_id)
self._panel.clear_fit_selection_if_missing()
self._refresh_fit_actions()
def clear(self):
"""Remove alignment overlay items from the plot and reset target state."""
self._clear_marker()
self._clear_target_line()
def cleanup(self):
"""Disconnect panel signals and remove all controller-owned overlay items."""
self.clear()
self._disconnect_panel_signals()
def refresh_theme_colors(self):
"""Reapply theme-aware styling to any existing alignment overlay items."""
self._apply_marker_style()
self._apply_target_style()
def _disconnect_panel_signals(self):
signal_pairs = [
(self._panel.position_readback_changed, self.update_position),
(self._panel.target_toggled, self._on_target_toggled),
(self._panel.target_move_requested, self._on_target_move_requested),
(self._panel.fit_selection_changed, self._on_fit_selection_changed),
(self._panel.fit_center_requested, self._on_fit_center_requested),
]
for signal, slot in signal_pairs:
try:
signal.disconnect(slot)
except (RuntimeError, TypeError):
continue
def _selected_fit_has_center(self) -> bool:
data = self._panel.selected_fit_summary()
params = data.get("params", []) if isinstance(data, dict) else []
return any(param[0] == "center" for param in params if param)
@staticmethod
def _status_message_for_context(context: AlignmentContext) -> str | None:
if context.positioner_name is None:
return "Alignment mode requires a positioner on the x axis."
if not context.has_dap_curves:
return "Add a DAP curve in Curve Settings to enable alignment fitting."
return None
def _refresh_fit_actions(self):
self._panel.set_fit_actions_enabled(
self._visible and self._positioner_name is not None and self._selected_fit_has_center()
)
def _refresh_target_controls(self):
has_positioner = self._visible and self._positioner_name is not None
self._panel.set_target_enabled(has_positioner)
self._panel.set_target_move_enabled(has_positioner and self._target_line is not None)
if self._target_line is None:
self._panel.set_target_value(None)
def _ensure_marker(self):
if self._marker_line is not None:
return
warning = get_accent_colors().warning
self._marker_line = pg.InfiniteLine(
angle=90,
movable=False,
pen=pg.mkPen(warning, width=4),
label="",
labelOpts={"position": 0.95, "color": warning},
)
self._apply_marker_style()
self._plot_item.addItem(self._marker_line)
def _clear_marker(self):
if self._marker_line is None:
return
self._plot_item.removeItem(self._marker_line)
self._marker_line = None
def _show_target_line(self):
if not self._visible or self._positioner_name is None:
return
if self._target_line is None:
accent_colors = get_accent_colors()
label = f"{self._positioner_name} target={{value:0.{self._precision}f}}"
self._target_line = pg.InfiniteLine(
movable=True,
angle=90,
pen=pg.mkPen(accent_colors.default, width=2, style=Qt.PenStyle.DashLine),
hoverPen=pg.mkPen(accent_colors.success, width=2),
label=label,
labelOpts={"movable": True, "color": accent_colors.default},
)
self._target_line.sigPositionChanged.connect(self._on_target_line_changed)
self._apply_target_style()
self._plot_item.addItem(self._target_line)
self._refresh_target_line_metadata()
value = 0.0 if self._readback is None else self._readback
if self._limits is not None:
value = min(max(value, self._limits[0]), self._limits[1])
self._target_line.setValue(value)
self._on_target_line_changed()
self.autoscale_requested.emit()
def _refresh_target_line_metadata(self):
if self._target_line is None or self._positioner_name is None:
return
self._apply_target_style()
self._target_line.label.setFormat(
f"{self._positioner_name} target={{value:0.{self._precision}f}}"
)
if self._limits is not None:
self._target_line.setBounds(list(self._limits))
else:
self._target_line.setBounds((None, None))
if self._limits is not None:
current_value = float(self._target_line.value())
clamped_value = min(max(current_value, self._limits[0]), self._limits[1])
if clamped_value != current_value:
self._target_line.setValue(clamped_value)
def _clear_target_line(self):
if self._target_line is not None:
try:
self._target_line.sigPositionChanged.disconnect(self._on_target_line_changed)
except (RuntimeError, TypeError):
pass
self._plot_item.removeItem(self._target_line)
self._target_line = None
self._panel.set_target_value(None)
def _apply_marker_style(self):
if self._marker_line is None:
return
accent_colors = get_accent_colors()
warning = accent_colors.warning
self._marker_line.setPen(pg.mkPen(warning, width=4))
self._marker_line.label.setColor(warning)
self._marker_line.label.fill = pg.mkBrush(self._label_fill_color())
def _apply_target_style(self):
if self._target_line is None:
return
accent_colors = get_accent_colors()
default = accent_colors.default
success = accent_colors.success
self._target_line.setPen(pg.mkPen(default, width=2, style=Qt.PenStyle.DashLine))
self._target_line.setHoverPen(pg.mkPen(success, width=2))
self._target_line.label.setColor(default)
self._target_line.label.fill = pg.mkBrush(self._label_fill_color())
@staticmethod
def _label_fill_color() -> QColor:
if get_theme_name() == "light":
return QColor(244, 244, 244, 228)
return QColor(48, 48, 48, 210)
@SafeSlot(bool)
def _on_target_toggled(self, checked: bool):
if checked:
self._show_target_line()
else:
self._clear_target_line()
self._refresh_target_controls()
@SafeSlot(object)
def _on_target_line_changed(self, _line=None):
if self._target_line is None:
return
self._panel.set_target_value(float(self._target_line.value()), precision=self._precision)
self._refresh_target_controls()
self.autoscale_requested.emit()
@SafeSlot()
def _on_target_move_requested(self):
if self._visible and self._positioner_name is not None and self._target_line is not None:
self.move_absolute_requested.emit(float(self._target_line.value()))
@SafeSlot(str)
def _on_fit_selection_changed(self, _curve_id: str):
self._refresh_fit_actions()
@SafeSlot(float)
def _on_fit_center_requested(self, value: float):
if self._visible and self._positioner_name is not None:
self.move_absolute_requested.emit(float(value))

View File

@@ -0,0 +1,285 @@
from __future__ import annotations
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QCheckBox,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QWidget,
)
from bec_widgets.utils.colors import get_accent_colors, get_theme_name
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
PositionerControlLine,
)
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
class WaveformAlignmentPanel(QWidget):
"""Compact bottom panel used by Waveform alignment mode."""
position_readback_changed = Signal(float)
target_toggled = Signal(bool)
target_move_requested = Signal()
fit_selection_changed = Signal(str)
fit_center_requested = Signal(float)
def __init__(self, parent=None, client=None, gui_id: str | None = None, **kwargs):
super().__init__(parent=parent, **kwargs)
self.setProperty("skip_settings", True)
self.positioner = PositionerControlLine(parent=self, client=client, gui_id=gui_id)
self.positioner.hide_device_selection = True
self.fit_dialog = LMFitDialog(
parent=self, client=client, gui_id=gui_id, ui_file="lmfit_dialog_compact.ui"
)
self.fit_dialog.active_action_list = ["center"]
self.fit_dialog.enable_actions = False
self.target_toggle = QCheckBox("Target: --", parent=self)
self.move_to_target_button = QPushButton("Move To Target", parent=self)
self.move_to_target_button.setEnabled(False)
self.target_group = QGroupBox("Target Position", parent=self)
self.status_label = QLabel(parent=self)
self.status_label.setWordWrap(False)
self.status_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
self.status_label.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
self.status_label.setMaximumHeight(28)
self.status_label.setVisible(False)
self._init_ui()
self.fit_dialog.setMinimumHeight(0)
self.target_group.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self._sync_target_group_size()
self.refresh_theme_colors()
self._connect_signals()
def _connect_signals(self):
self.positioner.position_update.connect(self.position_readback_changed)
self.target_toggle.toggled.connect(self.target_toggled)
self.move_to_target_button.clicked.connect(self.target_move_requested)
self.fit_dialog.selected_fit.connect(self.fit_selection_changed)
self.fit_dialog.move_action.connect(self._forward_fit_move_action)
def _init_ui(self):
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
self.setMinimumHeight(260)
root = QGridLayout(self)
root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(8)
self.fit_dialog.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
root.addWidget(
self.status_label,
0,
0,
1,
2,
alignment=Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter,
)
root.addWidget(self.fit_dialog, 1, 0, 1, 2)
target_layout = QHBoxLayout(self.target_group)
target_layout.addWidget(self.target_toggle)
target_layout.addWidget(self.move_to_target_button)
root.addWidget(self.positioner, 2, 0, alignment=Qt.AlignmentFlag.AlignTop)
root.addWidget(self.target_group, 2, 1, alignment=Qt.AlignmentFlag.AlignTop)
root.setColumnStretch(0, 1)
root.setColumnStretch(1, 0)
root.setRowStretch(1, 1)
def _sync_target_group_size(self):
representative_text = "Target: -99999.999"
label_width = max(
self.target_toggle.sizeHint().width(),
self.target_toggle.fontMetrics().horizontalAdvance(representative_text) + 24,
)
self.target_toggle.setMinimumWidth(label_width)
# To make those two box the same height
target_height = max(
self.positioner.height(),
self.positioner.ui.device_box.minimumSizeHint().height(),
self.positioner.ui.device_box.sizeHint().height(),
)
self.target_group.setFixedHeight(target_height)
self.target_group.setFixedWidth(self.target_group.sizeHint().width() + 16)
def showEvent(self, event):
super().showEvent(event)
self._sync_target_group_size()
def resizeEvent(self, event):
super().resizeEvent(event)
self._sync_target_group_size()
def set_status_message(self, text: str | None):
"""Show or hide the alignment status pill.
Args:
text: Message to display. Pass `None` or an empty string to hide the pill.
"""
text = text or ""
self.status_label.setText(text)
self.status_label.setVisible(bool(text))
@staticmethod
def _qcolor_to_rgba(color: QColor, alpha: int | None = None) -> str:
if alpha is not None:
color = QColor(color)
color.setAlpha(alpha)
return f"rgba({color.red()}, {color.green()}, {color.blue()}, {color.alpha()})"
def refresh_theme_colors(self):
"""Apply theme-aware accent styling to the status pill."""
warning = get_accent_colors().warning
is_light = get_theme_name() == "light"
text_color = "#202124" if is_light else warning.name()
fill_alpha = 72 if is_light else 48
border_alpha = 220 if is_light else 160
self.status_label.setStyleSheet(f"""
QLabel {{
background-color: {self._qcolor_to_rgba(warning, fill_alpha)};
border: 1px solid {self._qcolor_to_rgba(warning, border_alpha)};
border-radius: 12px;
padding: 4px 10px;
color: {text_color};
}}
""")
def set_positioner_device(self, device: str | None):
"""Bind the embedded positioner control to a fixed device.
Args:
device: Name of the positioner device to display, or `None` to clear it.
"""
if device is None:
self.positioner.ui.device_box.setTitle("No positioner selected")
return
if self.positioner.device != device:
self.positioner.set_positioner(device)
self.positioner.hide_device_selection = True
def set_positioner_enabled(self, enabled: bool):
"""Enable or disable the embedded positioner widget.
Args:
enabled: Whether the positioner widget should accept interaction.
"""
self.positioner.setEnabled(enabled)
def force_positioner_readback(self):
"""Trigger an immediate readback refresh on the embedded positioner widget."""
self.positioner.force_update_readback()
def set_target_enabled(self, enabled: bool):
"""Enable or disable the target-line toggle.
Args:
enabled: Whether the target toggle should accept interaction.
"""
self.target_toggle.setEnabled(enabled)
def set_target_move_enabled(self, enabled: bool):
"""Enable or disable the move-to-target button.
Args:
enabled: Whether the move button should accept interaction.
"""
self.move_to_target_button.setEnabled(enabled)
def set_target_active(self, active: bool):
"""Programmatically toggle the draggable target-line state.
Args:
active: Whether the target line should be considered active.
"""
blocker = self.target_toggle.blockSignals(True)
self.target_toggle.setChecked(active)
self.target_toggle.blockSignals(blocker)
if not active:
self.set_target_value(None)
def set_target_value(self, value: float | None, precision: int = 3) -> None:
"""
Update the target checkbox label for the draggable target line.
Args:
value(float | None): The target value to display. If None, the label will show "--".
precision(int): The number of decimal places to display for the target value.
"""
if value is None or not self.target_toggle.isChecked():
self.target_toggle.setText("Target: --")
return
self.target_toggle.setText(f"Target: {value:.{precision}f}")
def set_fit_actions_enabled(self, enabled: bool):
"""Enable or disable LMFit action buttons in the embedded fit dialog.
Args:
enabled: Whether fit action buttons should be enabled.
"""
self.fit_dialog.enable_actions = enabled
def update_dap_summary(self, data: dict, metadata: dict):
"""Forward a DAP summary update into the embedded fit dialog.
Args:
data: DAP fit summary payload.
metadata: Metadata describing the emitting DAP curve.
"""
self.fit_dialog.update_summary_tree(data, metadata)
def remove_dap_curve(self, curve_id: str):
"""Remove DAP summary state for a deleted fit curve.
Args:
curve_id: Label of the DAP curve that should be removed.
"""
self.fit_dialog.remove_dap_data(curve_id)
def clear_fit_selection_if_missing(self):
"""Select a remaining fit curve if the current selection no longer exists."""
fit_curve_id = self.fit_dialog.fit_curve_id
if fit_curve_id is not None and fit_curve_id not in self.fit_dialog.summary_data:
remaining = list(self.fit_dialog.summary_data)
self.fit_dialog.fit_curve_id = remaining[0] if remaining else None
@property
def target_active(self) -> bool:
"""Whether the target-line checkbox is currently checked."""
return self.target_toggle.isChecked()
@property
def selected_fit_curve_id(self) -> str | None:
"""Return the currently selected fit curve label, if any."""
return self.fit_dialog.fit_curve_id
def selected_fit_summary(self) -> dict | None:
"""Return the summary payload for the currently selected fit curve.
Returns:
The selected fit summary, or `None` if no fit curve is selected.
"""
fit_curve_id = self.selected_fit_curve_id
if fit_curve_id is None:
return None
return self.fit_dialog.summary_data.get(fit_curve_id)
def _forward_fit_move_action(self, action: tuple[str, float]):
param_name, param_value = action
if param_name == "center":
self.fit_center_requested.emit(float(param_value))

View File

@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Literal
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger, messages
from bec_lib.device import Positioner
from bec_lib.endpoints import MessageEndpoints
from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object
from bec_lib.scan_data_container import ScanDataContainer
@@ -30,11 +31,18 @@ from bec_widgets.utils.colors import Colors, apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.side_panel import SidePanel
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
from bec_widgets.widgets.plots.waveform.utils.alignment_controller import (
AlignmentContext,
WaveformAlignmentController,
)
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
from bec_widgets.widgets.plots.waveform.utils.roi_manager import WaveformROIManager
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
ScanHistoryBrowser,
@@ -156,6 +164,12 @@ class Waveform(PlotBase):
"label_suffix": "",
}
self._current_x_device: tuple[str, str] | None = None
self._alignment_panel_visible = False
self._alignment_side_panel: SidePanel | None = None
self._alignment_panel_index: int | None = None
self._alignment_panel: WaveformAlignmentPanel | None = None
self._alignment_controller: WaveformAlignmentController | None = None
self._alignment_positioner_name: str | None = None
# Specific GUI elements
self._init_roi_manager()
@@ -165,6 +179,7 @@ class Waveform(PlotBase):
self._add_waveform_specific_popup()
self._enable_roi_toolbar_action(False) # default state where are no dap curves
self._init_curve_dialog()
self._init_alignment_mode()
self.curve_settings_dialog = None
# Largedataset guard
@@ -195,7 +210,9 @@ class Waveform(PlotBase):
# To fix the ViewAll action with clipToView activated
self._connect_viewbox_menu_actions()
self.toolbar.show_bundles(["plot_export", "mouse_interaction", "roi", "axis_popup"])
self.toolbar.show_bundles(
["plot_export", "mouse_interaction", "roi", "alignment_mode", "axis_popup"]
)
def _connect_viewbox_menu_actions(self):
"""Connect the viewbox menu action ViewAll to the custom reset_view method."""
@@ -221,6 +238,12 @@ class Waveform(PlotBase):
theme(str, optional): The theme to be applied.
"""
self._refresh_colors()
alignment_panel = getattr(self, "_alignment_panel", None)
alignment_controller = getattr(self, "_alignment_controller", None)
if alignment_panel is not None:
alignment_panel.refresh_theme_colors()
if alignment_controller is not None:
alignment_controller.refresh_theme_colors()
super().apply_theme(theme)
def add_side_menus(self):
@@ -230,6 +253,159 @@ class Waveform(PlotBase):
super().add_side_menus()
self._add_dap_summary_side_menu()
def _init_alignment_mode(self):
"""
Initialize the top alignment panel.
"""
self.toolbar.components.add_safe(
"alignment_mode",
MaterialIconAction(
icon_name="align_horizontal_center",
tooltip="Show Alignment Mode",
checkable=True,
parent=self,
),
)
bundle = ToolbarBundle("alignment_mode", self.toolbar.components)
bundle.add_action("alignment_mode")
self.toolbar.add_bundle(bundle)
shown_bundles = list(self.toolbar.shown_bundles)
if "alignment_mode" not in shown_bundles:
shown_bundles.append("alignment_mode")
self.toolbar.show_bundles(shown_bundles)
self._alignment_side_panel = SidePanel(
parent=self, orientation="top", panel_max_width=320, show_toolbar=False
)
self.layout_manager.add_widget_relative(
self._alignment_side_panel,
self.round_plot_widget,
position="top",
shift_direction="down",
)
self._alignment_panel = WaveformAlignmentPanel(parent=self, client=self.client)
self._alignment_controller = WaveformAlignmentController(
self.plot_item, self._alignment_panel, parent=self
)
self._alignment_panel_index = self._alignment_side_panel.add_menu(
widget=self._alignment_panel
)
self._alignment_controller.move_absolute_requested.connect(self._move_alignment_positioner)
self._alignment_controller.autoscale_requested.connect(self._autoscale_alignment_indicators)
self.dap_summary_update.connect(self._alignment_controller.update_dap_summary)
self.toolbar.components.get_action("alignment_mode").action.toggled.connect(
self.toggle_alignment_mode
)
self._refresh_alignment_state()
@SafeSlot(bool)
def toggle_alignment_mode(self, checked: bool):
"""
Show or hide the alignment panel.
Args:
checked(bool): Whether the panel should be visible.
"""
if self._alignment_side_panel is None or self._alignment_panel_index is None:
return
self._alignment_panel_visible = checked
if checked:
self._alignment_side_panel.show_panel(self._alignment_panel_index)
self._refresh_alignment_state(force_readback=True)
self._refresh_dap_signals()
else:
self._alignment_side_panel.hide_panel()
self._refresh_alignment_state()
def _refresh_alignment_state(self, force_readback: bool = False):
"""
Refresh the alignment panel state after waveform changes.
Args:
force_readback(bool): Force a positioner readback refresh.
"""
if self._alignment_controller is None:
return
context = self._build_alignment_context(force_readback=force_readback)
self._alignment_positioner_name = context.positioner_name
self._alignment_controller.update_context(context)
def _resolve_alignment_positioner(self) -> str | None:
"""
Resolve the active x-axis positioner for alignment mode.
"""
if self.x_axis_mode["name"] in {"index", "timestamp"}:
return None
if self.x_axis_mode["name"] == "auto":
device_name = self._current_x_device[0] if self._current_x_device is not None else None
else:
device_name = self.x_axis_mode["name"]
if not device_name or device_name not in self.dev:
return None
if not isinstance(self.dev[device_name], Positioner):
return None
return device_name
def _build_alignment_context(self, force_readback: bool = False) -> AlignmentContext:
"""Build controller-facing alignment context from waveform/device state."""
positioner_name = self._resolve_alignment_positioner()
if positioner_name is None:
return AlignmentContext(
visible=self._alignment_panel_visible,
positioner_name=None,
has_dap_curves=bool(self._dap_curves),
force_readback=force_readback,
)
precision = getattr(self.dev[positioner_name], "precision", 3)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = 3
limits = getattr(self.dev[positioner_name], "limits", None)
parsed_limits: tuple[float, float] | None = None
if limits is not None and len(limits) == 2:
low, high = float(limits[0]), float(limits[1])
if low != 0 or high != 0:
if low > high:
low, high = high, low
parsed_limits = (low, high)
data = self.dev[positioner_name].read(cached=True)
value = data.get(positioner_name, {}).get("value")
readback = None if value is None else float(value)
return AlignmentContext(
visible=self._alignment_panel_visible,
positioner_name=positioner_name,
precision=precision,
limits=parsed_limits,
readback=readback,
has_dap_curves=bool(self._dap_curves),
force_readback=force_readback,
)
@SafeSlot(float)
def _move_alignment_positioner(self, value: float):
"""
Move the active alignment positioner to an absolute value requested by the controller.
"""
if self._alignment_positioner_name is None:
return
self.dev[self._alignment_positioner_name].move(float(value), relative=False)
@SafeSlot()
def _autoscale_alignment_indicators(self):
"""Autoscale the waveform view after alignment indicator updates."""
self._reset_view()
def _add_waveform_specific_popup(self):
"""
Add popups to the Waveform widget.
@@ -266,7 +442,7 @@ class Waveform(PlotBase):
Due to setting clipToView to True on the curves, the autoRange() method
of the ViewBox does no longer work as expected. This method deactivates the
setClipToView for all curves, calls autoRange() to circumvent that issue.
Afterwards, it re-enables the setClipToView for all curves again.
Afterward, it re-enables the setClipToView for all curves again.
It is hooked to the ViewAll action in the right-click menu of the pg.PlotItem ViewBox.
"""
@@ -544,6 +720,7 @@ class Waveform(PlotBase):
self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True)
self.round_plot_widget.apply_plot_widget_style() # To keep the correct theme
self._refresh_alignment_state(force_readback=True)
@SafeProperty(str)
def signal_x(self) -> str | None:
@@ -573,6 +750,7 @@ class Waveform(PlotBase):
self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True)
self.round_plot_widget.apply_plot_widget_style()
self._refresh_alignment_state(force_readback=True)
@SafeProperty(str)
def color_palette(self) -> str:
@@ -627,6 +805,8 @@ class Waveform(PlotBase):
continue
config = CurveConfig(**cfg_dict)
self._add_curve(config=config)
self._refresh_alignment_state(force_readback=self._alignment_panel_visible)
self._refresh_dap_signals()
except json.JSONDecodeError as e:
logger.error(f"Failed to decode JSON: {e}")
@@ -1002,6 +1182,7 @@ class Waveform(PlotBase):
QTimer.singleShot(
150, self.auto_range
) # autorange with a delay to ensure the plot is updated
self._refresh_alignment_state()
return curve
@@ -1257,6 +1438,7 @@ class Waveform(PlotBase):
self.remove_curve(curve.name())
if self.crosshair is not None:
self.crosshair.clear_markers()
self._refresh_alignment_state()
def get_curve(self, curve: int | str) -> Curve | None:
"""
@@ -1292,6 +1474,7 @@ class Waveform(PlotBase):
self._refresh_colors()
self._categorise_device_curves()
self._refresh_alignment_state()
def _remove_curve_by_name(self, name: str):
"""
@@ -1342,6 +1525,8 @@ class Waveform(PlotBase):
and self.enable_side_panel is True
):
self.dap_summary.remove_dap_data(curve.name())
if curve.config.source == "dap" and self._alignment_controller is not None:
self._alignment_controller.remove_dap_curve(curve.name())
# find a corresponding dap curve and remove it
for c in self.curves:
@@ -1778,7 +1963,7 @@ class Waveform(PlotBase):
if parent_curve is None:
logger.warning(
f"No device curve found for DAP curve '{dap_curve.name()}'!"
) # TODO triggerd when DAP curve is removed from the curve dialog, why?
) # TODO triggered when DAP curve is removed from the curve dialog, why?
continue
x_data, y_data = parent_curve.get_data()
@@ -1983,6 +2168,7 @@ class Waveform(PlotBase):
"""
x_data = None
new_suffix = None
previous_x_device = self._current_x_device
data, access_key = self._fetch_scan_data_and_access()
# 1 User wants custom signal
@@ -2041,6 +2227,7 @@ class Waveform(PlotBase):
if not scan_report_devices:
x_data = None
new_suffix = " (auto: index)"
self._current_x_device = None
else:
device_x = scan_report_devices[0]
signal_x = self.entry_validator.validate_signal(device_x, None)
@@ -2050,8 +2237,10 @@ class Waveform(PlotBase):
entry_obj = data.get(device_x, {}).get(signal_x)
x_data = entry_obj.read()["value"] if entry_obj else None
new_suffix = f" (auto: {device_x}-{signal_x})"
self._current_x_device = (device_x, signal_x)
self._current_x_device = (device_x, signal_x)
self._update_x_label_suffix(new_suffix)
if previous_x_device != self._current_x_device:
self._refresh_alignment_state(force_readback=True)
return x_data
def _update_x_label_suffix(self, new_suffix: str):
@@ -2096,7 +2285,7 @@ class Waveform(PlotBase):
def _categorise_device_curves(self) -> str:
"""
Categorise the device curves into sync and async based on the readout priority.
Categorize the device curves into sync and async based on the readout priority.
"""
if self.scan_item is None:
self.update_with_scan_history(-1)
@@ -2453,6 +2642,8 @@ class Waveform(PlotBase):
Cleanup the widget by disconnecting signals and closing dialogs.
"""
self.proxy_dap_request.cleanup()
if self._alignment_controller is not None:
self._alignment_controller.cleanup()
self.clear_all()
if self.curve_settings_dialog is not None:
self.curve_settings_dialog.reject()

View File

@@ -9,7 +9,8 @@ from qtpy import QtCore, QtGui
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets import BECWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
@@ -40,7 +41,7 @@ class ProgressbarConfig(ConnectionConfig):
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 "
description="Start position for the progress bars in degrees. Default is 90 degrees - corresponds to "
"the top of the ring.",
)
min_value: int | float = Field(0, description="Minimum value for the progress bars.")
@@ -59,7 +60,7 @@ class ProgressbarConfig(ConnectionConfig):
)
class Ring(BECConnector, QWidget):
class Ring(BECWidget, QWidget):
USER_ACCESS = [
"set_value",
"set_color",
@@ -82,8 +83,26 @@ class Ring(BECConnector, QWidget):
self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None
self.RID = None
self._gap = 5
self._hovered = False
self._hover_progress = 0.0
self._hover_animation = QtCore.QPropertyAnimation(self, b"hover_progress", parent=self)
self._hover_animation.setDuration(180)
easing_curve = (
QtCore.QEasingCurve.Type.OutCubic
if hasattr(QtCore.QEasingCurve, "Type")
else QtCore.QEasingCurve.Type.OutCubic
)
self._hover_animation.setEasingCurve(easing_curve)
self.set_start_angle(self.config.start_position)
def _request_update(self, *, refresh_tooltip: bool = True):
# NOTE why not just overwrite update() to always refresh the tooltip?
# Because in some cases (e.g. hover animation) we want to update the widget without refreshing the tooltip, to avoid performance issues.
if refresh_tooltip:
if self.progress_container and self.progress_container.is_ring_hovered(self):
self.progress_container.refresh_hover_tooltip(self)
self.update()
def set_value(self, value: int | float):
"""
Set the value for the ring widget
@@ -107,7 +126,7 @@ class Ring(BECConnector, QWidget):
if self.config.link_colors:
self._auto_set_background_color()
self.update()
self._request_update()
def set_background(self, color: str | tuple | QColor):
"""
@@ -122,7 +141,7 @@ class Ring(BECConnector, QWidget):
self._background_color = self.convert_color(color)
self.config.background_color = self._background_color.name()
self.update()
self._request_update()
def _auto_set_background_color(self):
"""
@@ -133,7 +152,7 @@ class Ring(BECConnector, QWidget):
bg_color = Colors.subtle_background_color(self._color, bg)
self.config.background_color = bg_color.name()
self._background_color = bg_color
self.update()
self._request_update()
def set_colors_linked(self, linked: bool):
"""
@@ -146,7 +165,7 @@ class Ring(BECConnector, QWidget):
self.config.link_colors = linked
if linked:
self._auto_set_background_color()
self.update()
self._request_update()
def set_line_width(self, width: int):
"""
@@ -156,7 +175,7 @@ class Ring(BECConnector, QWidget):
width(int): Line width for the ring widget
"""
self.config.line_width = width
self.update()
self._request_update()
def set_min_max_values(self, min_value: int | float, max_value: int | float):
"""
@@ -168,7 +187,7 @@ class Ring(BECConnector, QWidget):
"""
self.config.min_value = min_value
self.config.max_value = max_value
self.update()
self._request_update()
def set_start_angle(self, start_angle: int):
"""
@@ -178,7 +197,7 @@ class Ring(BECConnector, QWidget):
start_angle(int): Start angle for the ring widget in degrees
"""
self.config.start_position = start_angle
self.update()
self._request_update()
def set_update(
self, mode: Literal["manual", "scan", "device"], device: str = "", signal: str = ""
@@ -237,7 +256,7 @@ class Ring(BECConnector, QWidget):
precision(int): Precision for the ring widget
"""
self.config.precision = precision
self.update()
self._request_update()
def set_direction(self, direction: int):
"""
@@ -247,7 +266,7 @@ class Ring(BECConnector, QWidget):
direction(int): Direction for the ring widget. -1 for clockwise, 1 for counter-clockwise.
"""
self.config.direction = direction
self.update()
self._request_update()
def _get_signals_for_device(self, device: str) -> dict[str, list[str]]:
"""
@@ -276,7 +295,7 @@ class Ring(BECConnector, QWidget):
for obj in dev_obj._info["signals"].values()
if obj["kind_str"] == "hinted"
and obj["signal_class"]
not in ["ProgressSignal", "AyncSignal", "AsyncMultiSignal", "DynamicSignal"]
not in ["ProgressSignal", "AsyncSignal", "AsyncMultiSignal", "DynamicSignal"]
]
normal_signals = [
@@ -424,8 +443,11 @@ class Ring(BECConnector, QWidget):
rect.adjust(max_ring_size, max_ring_size, -max_ring_size, -max_ring_size)
# Background arc
base_line_width = float(self.config.line_width)
hover_line_delta = min(3.0, round(base_line_width * 0.6, 1))
current_line_width = base_line_width + (hover_line_delta * self._hover_progress)
painter.setPen(
QtGui.QPen(self._background_color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
QtGui.QPen(self._background_color, current_line_width, QtCore.Qt.PenStyle.SolidLine)
)
gap: int = self.gap # type: ignore
@@ -433,13 +455,25 @@ class Ring(BECConnector, QWidget):
# 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(
adjusted_rect = QtCore.QRectF(
rect.left() + gap, rect.top() + gap, rect.width() - 2 * gap, rect.height() - 2 * gap
)
if self._hover_progress > 0.0:
hover_radius_delta = 4.0
base_radius = adjusted_rect.width() / 2
if base_radius > 0:
target_radius = base_radius + (hover_radius_delta * self._hover_progress)
scale = target_radius / base_radius
center = adjusted_rect.center()
new_width = adjusted_rect.width() * scale
new_height = adjusted_rect.height() * scale
adjusted_rect = QtCore.QRectF(
center.x() - new_width / 2, center.y() - new_height / 2, new_width, new_height
)
painter.drawArc(adjusted_rect, start_position, 360 * 16)
# Foreground arc
pen = QtGui.QPen(self.color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
pen = QtGui.QPen(self.color, current_line_width, QtCore.Qt.PenStyle.SolidLine)
pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
proportion = (self.config.value - self.config.min_value) / (
@@ -449,7 +483,17 @@ class Ring(BECConnector, QWidget):
painter.drawArc(adjusted_rect, start_position, angle)
painter.end()
def convert_color(self, color: str | tuple | QColor) -> QColor:
def set_hovered(self, hovered: bool):
if hovered == self._hovered:
return
self._hovered = hovered
self._hover_animation.stop()
self._hover_animation.setStartValue(self._hover_progress)
self._hover_animation.setEndValue(1.0 if hovered else 0.0)
self._hover_animation.start()
@staticmethod
def convert_color(color: str | tuple | QColor) -> QColor:
"""
Convert the color to QColor
@@ -485,7 +529,7 @@ class Ring(BECConnector, QWidget):
@gap.setter
def gap(self, value: int):
self._gap = value
self.update()
self._request_update()
@SafeProperty(bool)
def link_colors(self) -> bool:
@@ -522,7 +566,7 @@ class Ring(BECConnector, QWidget):
float(max(self.config.min_value, min(self.config.max_value, value))),
self.config.precision,
)
self.update()
self._request_update()
@SafeProperty(float)
def min_value(self) -> float:
@@ -531,7 +575,7 @@ class Ring(BECConnector, QWidget):
@min_value.setter
def min_value(self, value: float):
self.config.min_value = value
self.update()
self._request_update()
@SafeProperty(float)
def max_value(self) -> float:
@@ -540,7 +584,7 @@ class Ring(BECConnector, QWidget):
@max_value.setter
def max_value(self, value: float):
self.config.max_value = value
self.update()
self._request_update()
@SafeProperty(str)
def mode(self) -> str:
@@ -549,6 +593,7 @@ class Ring(BECConnector, QWidget):
@mode.setter
def mode(self, value: str):
self.set_update(value)
self._request_update()
@SafeProperty(str)
def device(self) -> str:
@@ -557,6 +602,7 @@ class Ring(BECConnector, QWidget):
@device.setter
def device(self, value: str):
self.config.device = value
self._request_update()
@SafeProperty(str)
def signal(self) -> str:
@@ -565,6 +611,7 @@ class Ring(BECConnector, QWidget):
@signal.setter
def signal(self, value: str):
self.config.signal = value
self._request_update()
@SafeProperty(int)
def line_width(self) -> int:
@@ -573,7 +620,7 @@ class Ring(BECConnector, QWidget):
@line_width.setter
def line_width(self, value: int):
self.config.line_width = value
self.update()
self._request_update()
@SafeProperty(int)
def start_position(self) -> int:
@@ -582,7 +629,7 @@ class Ring(BECConnector, QWidget):
@start_position.setter
def start_position(self, value: int):
self.config.start_position = value
self.update()
self._request_update()
@SafeProperty(int)
def precision(self) -> int:
@@ -591,7 +638,7 @@ class Ring(BECConnector, QWidget):
@precision.setter
def precision(self, value: int):
self.config.precision = value
self.update()
self._request_update()
@SafeProperty(int)
def direction(self) -> int:
@@ -600,7 +647,27 @@ class Ring(BECConnector, QWidget):
@direction.setter
def direction(self, value: int):
self.config.direction = value
self.update()
self._request_update()
@SafeProperty(float)
def hover_progress(self) -> float:
return self._hover_progress
@hover_progress.setter
def hover_progress(self, value: float):
self._hover_progress = value
self._request_update(refresh_tooltip=False)
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
self._hover_animation.stop()
super().cleanup()
if __name__ == "__main__": # pragma: no cover

View File

@@ -3,7 +3,7 @@ from typing import Literal
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt
from qtpy.QtCore import QPointF, QSize, Qt
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
from bec_widgets.utils import Colors
@@ -12,6 +12,7 @@ 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.containers.main_window.addons.hover_widget import WidgetTooltip
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
@@ -29,7 +30,16 @@ class RingProgressContainerWidget(QWidget):
self.rings: list[Ring] = []
self.gap = 20 # Gap between rings
self.color_map: str = "turbo"
self._hovered_ring: Ring | None = None
self._last_hover_global_pos = None
self._hover_tooltip_label = QLabel()
self._hover_tooltip_label.setWordWrap(True)
self._hover_tooltip_label.setTextFormat(Qt.TextFormat.PlainText)
self._hover_tooltip_label.setMaximumWidth(260)
self._hover_tooltip_label.setStyleSheet("font-size: 12px;")
self._hover_tooltip = WidgetTooltip(self._hover_tooltip_label)
self.setLayout(QHBoxLayout())
self.setMouseTracking(True)
self.initialize_bars()
self.initialize_center_label()
@@ -59,6 +69,7 @@ class RingProgressContainerWidget(QWidget):
"""
ring = Ring(parent=self)
ring.setGeometry(self.rect())
ring.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
ring.gap = self.gap * len(self.rings)
ring.set_value(0)
self.rings.append(ring)
@@ -88,6 +99,10 @@ class RingProgressContainerWidget(QWidget):
index = self.num_bars - 1
index = self._validate_index(index)
ring = self.rings[index]
if ring is self._hovered_ring:
self._hovered_ring = None
self._last_hover_global_pos = None
self._hover_tooltip.hide()
ring.cleanup()
ring.close()
ring.deleteLater()
@@ -106,6 +121,7 @@ class RingProgressContainerWidget(QWidget):
self.center_label = QLabel("", parent=self)
self.center_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.center_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
layout.addWidget(self.center_label)
def _calculate_minimum_size(self):
@@ -150,6 +166,130 @@ class RingProgressContainerWidget(QWidget):
for ring in self.rings:
ring.setGeometry(self.rect())
def enterEvent(self, event):
self.setMouseTracking(True)
super().enterEvent(event)
def mouseMoveEvent(self, event):
pos = event.position() if hasattr(event, "position") else QPointF(event.pos())
self._last_hover_global_pos = (
event.globalPosition().toPoint()
if hasattr(event, "globalPosition")
else event.globalPos()
)
ring = self._ring_at_pos(pos)
self._set_hovered_ring(ring, event)
super().mouseMoveEvent(event)
def leaveEvent(self, event):
self._last_hover_global_pos = None
self._set_hovered_ring(None, event)
super().leaveEvent(event)
def _set_hovered_ring(self, ring: Ring | None, event=None):
if ring is self._hovered_ring:
if ring is not None:
self.refresh_hover_tooltip(ring, event)
return
if self._hovered_ring is not None:
self._hovered_ring.set_hovered(False)
self._hovered_ring = ring
if self._hovered_ring is not None:
self._hovered_ring.set_hovered(True)
self.refresh_hover_tooltip(self._hovered_ring, event)
else:
self._hover_tooltip.hide()
def _ring_at_pos(self, pos: QPointF) -> Ring | None:
if not self.rings:
return None
size = min(self.width(), self.height())
if size <= 0:
return None
x_offset = (self.width() - size) / 2
y_offset = (self.height() - size) / 2
center_x = x_offset + size / 2
center_y = y_offset + size / 2
dx = pos.x() - center_x
dy = pos.y() - center_y
distance = (dx * dx + dy * dy) ** 0.5
max_ring_size = self.get_max_ring_size()
base_radius = (size - 2 * max_ring_size) / 2
if base_radius <= 0:
return None
best_ring: Ring | None = None
best_delta: float | None = None
for ring in self.rings:
radius = base_radius - ring.gap
if radius <= 0:
continue
half_width = ring.config.line_width / 2
inner = radius - half_width
outer = radius + half_width
if inner <= distance <= outer:
delta = abs(distance - radius)
if best_delta is None or delta < best_delta:
best_delta = delta
best_ring = ring
return best_ring
def is_ring_hovered(self, ring: Ring) -> bool:
return ring is self._hovered_ring
def refresh_hover_tooltip(self, ring: Ring, event=None):
text = self._build_tooltip_text(ring)
if event is not None:
self._last_hover_global_pos = (
event.globalPosition().toPoint()
if hasattr(event, "globalPosition")
else event.globalPos()
)
if self._last_hover_global_pos is None:
return
self._hover_tooltip_label.setText(text)
self._hover_tooltip.apply_theme()
self._hover_tooltip.show_near(self._last_hover_global_pos)
@staticmethod
def _build_tooltip_text(ring: Ring) -> str:
mode = ring.config.mode
mode_label = {"manual": "Manual", "scan": "Scan progress", "device": "Device"}.get(
mode, mode
)
precision = int(ring.config.precision)
value = ring.config.value
min_value = ring.config.min_value
max_value = ring.config.max_value
range_span = max(max_value - min_value, 1e-9)
progress = max(0.0, min(100.0, ((value - min_value) / range_span) * 100))
lines = [
f"Mode: {mode_label}",
f"Progress: {value:.{precision}f} / {max_value:.{precision}f} ({progress:.1f}%)",
]
if min_value != 0:
lines.append(f"Range: {min_value:.{precision}f} -> {max_value:.{precision}f}")
if mode == "device" and ring.config.device:
if ring.config.signal:
lines.append(f"Device: {ring.config.device}:{ring.config.signal}")
else:
lines.append(f"Device: {ring.config.device}")
return "\n".join(lines)
def closeEvent(self, event):
# Ensure the hover tooltip is properly cleaned up when this widget closes
tooltip = getattr(self, "_hover_tooltip", None)
if tooltip is not None:
tooltip.close()
tooltip.deleteLater()
self._hover_tooltip = None
super().closeEvent(event)
def set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "RGB"):
"""
Set the colors for the progress bars from a colormap.
@@ -230,6 +370,9 @@ class RingProgressContainerWidget(QWidget):
"""
Clear all rings from the widget.
"""
self._hovered_ring = None
self._last_hover_global_pos = None
self._hover_tooltip.hide()
for ring in self.rings:
ring.close()
ring.deleteLater()

View File

@@ -63,7 +63,8 @@ class RingCardWidget(QFrame):
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:
@staticmethod
def _get_theme_color(color_name: str) -> QColor | None:
app = QApplication.instance()
if not app:
return
@@ -249,12 +250,13 @@ class RingCardWidget(QFrame):
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:
if not device or device not in self.ring.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:
@staticmethod
def _unify_mode_string(mode: str) -> str:
"""Convert mode string to a unified format"""
mode = mode.lower()
if mode == "scan progress":
@@ -263,7 +265,8 @@ class RingCardWidget(QFrame):
return "device"
return mode
def _get_display_mode_string(self, mode: str) -> str:
@staticmethod
def _get_display_mode_string(mode: str) -> str:
"""Convert mode string to display format"""
match mode:
case "manual":

View File

@@ -42,9 +42,6 @@ from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.expe
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_selection import (
ExperimentSelection,
)
from bec_widgets.widgets.services.bec_messaging_config.bec_messaging_config_widget import (
BECMessagingConfigWidget,
)
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QToolBar
@@ -107,8 +104,7 @@ class OverviewWidget(QGroupBox):
content_layout = QVBoxLayout(content)
content.setFrameShape(QFrame.Shape.StyledPanel)
content.setFrameShadow(QFrame.Shadow.Raised)
content.setStyleSheet(
"""
content.setStyleSheet("""
QFrame
{
border: 1px solid #cccccc;
@@ -117,8 +113,7 @@ class OverviewWidget(QGroupBox):
{
border: none;
}
"""
)
""")
content_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
content.setFixedSize(400, 280)
@@ -256,7 +251,7 @@ class BECAtlasAdminView(BECWidget, QWidget):
def __init__(
self,
parent=None,
atlas_url: str = "https://bec-atlas-dev.psi.ch/api/v1",
atlas_url: str = "https://bec-atlas-prod.psi.ch/api/v1",
client=None,
**kwargs,
):
@@ -304,11 +299,6 @@ class BECAtlasAdminView(BECWidget, QWidget):
self.experiment_selection.setVisible(False)
self.stacked_layout.addWidget(self.experiment_selection)
# Messaging Services widget
self.messaging_config_widget = BECMessagingConfigWidget(parent=self)
self.messaging_config_widget.setVisible(False)
self.stacked_layout.addWidget(self.messaging_config_widget)
# Connect signals
self.overview_widget.login_requested.connect(self._on_login_requested)
self.overview_widget.change_experiment_requested.connect(
@@ -402,7 +392,6 @@ class BECAtlasAdminView(BECWidget, QWidget):
"""Show the overview panel."""
self.overview_widget.setVisible(True)
self.experiment_selection.setVisible(False)
self.messaging_config_widget.setVisible(False)
self.stacked_layout.setCurrentWidget(self.overview_widget)
def _on_experiment_selection_selected(self):
@@ -412,20 +401,12 @@ class BECAtlasAdminView(BECWidget, QWidget):
return
self.overview_widget.setVisible(False)
self.experiment_selection.setVisible(True)
self.messaging_config_widget.setVisible(False)
self.stacked_layout.setCurrentWidget(self.experiment_selection)
def _on_messaging_services_selected(self):
"""Show the messaging services panel."""
if not self._authenticated:
logger.warning("Attempted to access messaging services without authentication.")
return
self.overview_widget.setVisible(False)
self.experiment_selection.setVisible(False)
self.messaging_config_widget.setVisible(True)
if self._current_deployment_info is not None:
self.messaging_config_widget.populate_from_deployment(self._current_deployment_info)
self.stacked_layout.setCurrentWidget(self.messaging_config_widget)
logger.info("Messaging services panel is not implemented yet.")
return
########################
## Internal slots
@@ -465,7 +446,6 @@ class BECAtlasAdminView(BECWidget, QWidget):
atlas_url=self._atlas_url,
)
self.atlas_http_service._set_current_deployment_info(deployment)
self.messaging_config_widget.populate_from_deployment(deployment)
def _fetch_available_experiments(self):
"""Fetch the list of available experiments for the authenticated user."""
@@ -521,7 +501,9 @@ class BECAtlasAdminView(BECWidget, QWidget):
if authenticated:
self.toolbar.components.get_action("experiment_selection").action.setEnabled(True)
self.toolbar.components.get_action("messaging_services").action.setEnabled(True)
self.toolbar.components.get_action("messaging_services").action.setEnabled(
False
) # TODO activate once messaging is added
self.toolbar.components.get_action("logout").action.setEnabled(True)
self._fetch_available_experiments() # Fetch experiments upon successful authentication
self._atlas_info_widget.set_logged_in(info.email)

View File

@@ -142,6 +142,17 @@ class BECAtlasHTTPService(QWidget):
if self._auth_user_info is not None:
self._auth_user_info.groups = set(groups)
def __check_access_to_owner_groups(self, groups: list[str]) -> bool:
"""Check if the authenticated user has access to the current deployment based on their groups."""
if self._auth_user_info is None or self._current_deployment_info is None:
return False
# Admin user
has_both = {"admin", "atlas_func_account"}.issubset(groups)
if has_both:
return True
# Regular user check with group intersection
return not self.auth_user_info.groups.isdisjoint(groups)
def __clear_login_info(self, skip_logout: bool = False):
"""Clear the authenticated user information after logout."""
self._auth_user_info = None
@@ -231,9 +242,7 @@ class BECAtlasHTTPService(QWidget):
)
elif AtlasEndpoints.DEPLOYMENT_INFO.value in request_url:
owner_groups = data.get("owner_groups", [])
if self.auth_user_info is not None and not self.auth_user_info.groups.isdisjoint(
owner_groups
):
if self.__check_access_to_owner_groups(owner_groups):
self.authenticated.emit(self.auth_user_info.model_dump())
else:
if self.auth_user_info is not None:

View File

@@ -1,315 +0,0 @@
"""Module for the BEC messaging configuration widget."""
from __future__ import annotations
import json
from qtpy.QtCore import Qt, QTimer, Signal # type: ignore[attr-defined]
from qtpy.QtWidgets import (
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSplitter,
QTabWidget,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.services.bec_messaging_config.service_cards import (
CardType,
ScopeListWidget,
card_from_service,
make_card,
)
from bec_widgets.widgets.services.bec_messaging_config.service_scope_event_table import (
ServiceScopeEventTableWidget,
)
class ServiceConfigPanel(QWidget):
"""Panel that manages global and local service scopes for one service type.
Args:
card_type (CardType): The service type used when adding new scope cards.
parent (QWidget | None): The parent widget.
"""
config_changed = Signal()
def __init__(self, card_type: CardType, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._card_type: CardType = card_type
root = QVBoxLayout(self)
root.setContentsMargins(12, 12, 12, 12)
root.setSpacing(0)
splitter = QSplitter(Qt.Orientation.Vertical)
# ── Local settings box ────────────────────────────────────────────
self._local_box = QGroupBox("Current Experiment")
self._local_list = ScopeListWidget()
self._local_list.cards_changed.connect(self.config_changed)
self._local_add_btn = QPushButton("+ Add")
self._local_add_btn.setFixedWidth(120)
self._local_add_btn.clicked.connect(
lambda: self._local_list.add_card(make_card(self._card_type))
)
local_layout = QVBoxLayout(self._local_box)
local_layout.setContentsMargins(16, 16, 16, 16)
local_layout.setSpacing(12)
local_layout.addWidget(self._local_add_btn, 0, Qt.AlignmentFlag.AlignRight)
local_layout.addWidget(self._local_list, 1)
splitter.addWidget(self._local_box)
# ── Global settings box ───────────────────────────────────────────
self._global_box = QGroupBox("All Experiments")
self._global_list = ScopeListWidget()
self._global_list.cards_changed.connect(self.config_changed)
self._global_add_btn = QPushButton("+ Add")
self._global_add_btn.setFixedWidth(120)
self._global_add_btn.clicked.connect(
lambda: self._global_list.add_card(make_card(self._card_type))
)
global_layout = QVBoxLayout(self._global_box)
global_layout.setContentsMargins(16, 16, 16, 16)
global_layout.setSpacing(12)
global_layout.addWidget(self._global_add_btn, 0, Qt.AlignmentFlag.AlignRight)
global_layout.addWidget(self._global_list, 1)
splitter.addWidget(self._global_box)
splitter.setSizes([300, 300])
root.addWidget(splitter, 1)
# ------------------------------------------------------------------
def load_services(self, deployment_services: list, session_services: list) -> None:
"""Populate both lists with services matching the panel service type."""
self._clear_list(self._global_list)
self._clear_list(self._local_list)
for info in deployment_services:
if getattr(info, "service_type", None) == self._card_type:
self._global_list.add_card(card_from_service(info))
for info in session_services:
if getattr(info, "service_type", None) == self._card_type:
self._local_list.add_card(card_from_service(info))
@staticmethod
def _clear_list(list_widget: ScopeListWidget) -> None:
"""Remove all cards from *list_widget*."""
list_widget.clear_cards()
# ------------------------------------------------------------------
def get_data(self) -> dict:
"""Collect all card data from both the deployment and session lists."""
return {
"deployment": self._collect(self._global_list),
"session": self._collect(self._local_list),
}
@staticmethod
def _collect(list_widget: ScopeListWidget) -> list[dict]:
return [card.get_data() for card in list_widget.cards()]
class BECMessagingConfigWidget(QWidget):
"""Widget to configure SciLog, Signal, and MS Teams messaging services."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("BEC Messaging Configuration")
self.setMinimumSize(540, 500)
root = QVBoxLayout(self)
root.setContentsMargins(16, 16, 16, 16)
root.setSpacing(12)
content_splitter = QSplitter(Qt.Orientation.Horizontal)
# ── Tab widget ────────────────────────────────────────────────────
self._tabs = QTabWidget()
self._scilog_panel = ServiceConfigPanel("scilog")
self._signal_panel = ServiceConfigPanel("signal")
self._teams_panel = ServiceConfigPanel("teams")
for panel in (self._scilog_panel, self._signal_panel, self._teams_panel):
panel.config_changed.connect(self._refresh_scope_event_table)
self._tabs.addTab(self._scilog_panel, "SciLog")
self._tabs.addTab(self._signal_panel, "Signal")
self._tabs.addTab(self._teams_panel, "MS Teams")
content_splitter.addWidget(self._tabs)
self._scope_event_table = ServiceScopeEventTableWidget(self)
content_splitter.addWidget(self._scope_event_table)
content_splitter.setStretchFactor(0, 3)
content_splitter.setStretchFactor(1, 2)
root.addWidget(content_splitter, 1)
# ── Bottom action bar ─────────────────────────────────────────────
bottom_row = QHBoxLayout()
bottom_row.setSpacing(12)
self._status_label = QLabel("")
self._status_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
bottom_row.addWidget(self._status_label, 1)
save_btn = QPushButton("Save && Apply")
save_btn.setDefault(True)
save_btn.clicked.connect(self._mock_save_to_atlas_api)
bottom_row.addWidget(save_btn)
root.addLayout(bottom_row)
# ------------------------------------------------------------------
# Initialisation from backend message
# ------------------------------------------------------------------
def populate_from_deployment(self, msg: DeploymentInfoMessage) -> None:
"""Populate all panels from a deployment info message.
Args:
msg (DeploymentInfoMessage): Deployment information containing deployment and session services.
"""
deployment_services = list(msg.messaging_services)
session_services = (
list(msg.active_session.messaging_services) if msg.active_session is not None else []
)
self._scilog_panel.load_services(deployment_services, session_services)
self._signal_panel.load_services(deployment_services, session_services)
self._teams_panel.load_services(deployment_services, session_services)
self._refresh_scope_event_table()
# ------------------------------------------------------------------
# Dummy REST methods (replace with real requests calls later)
# ------------------------------------------------------------------
def _build_payload(self) -> dict:
"""Collect the current UI state as a serializable dictionary."""
return {
"scilog": self._scilog_panel.get_data(),
"signal": self._signal_panel.get_data(),
"teams": self._teams_panel.get_data(),
"event_subscriptions": self._scope_event_table.get_data(),
}
def _refresh_scope_event_table(self) -> None:
"""Refresh the event subscription table from the current service cards."""
self._scope_event_table.set_services(self._collect_services_for_event_table())
def _collect_services_for_event_table(self) -> list[dict]:
"""Collect all configured services for the event subscription table."""
service_rows: list[dict] = []
for panel in (self._scilog_panel, self._signal_panel, self._teams_panel):
panel_data = panel.get_data()
for source_name in ("deployment", "session"):
for service in panel_data[source_name]:
service_rows.append({**service, "source": source_name})
return service_rows
def _mock_save_to_atlas_api(self) -> None:
"""Simulate saving the current configuration to Atlas."""
payload = self._build_payload()
print("" * 60)
print("[BECMessagingConfigWidget] _mock_save_to_atlas_api payload:")
print(json.dumps(payload, indent=2))
print("" * 60)
self._set_status("✅ Saved!", timeout_ms=4000)
# ------------------------------------------------------------------
# Status bar helper
# ------------------------------------------------------------------
def _set_status(self, message: str, *, timeout_ms: int = 0) -> None:
"""Show a status message and optionally clear it after a timeout.
Args:
message (str): The message to display in the status label.
timeout_ms (int): Time in milliseconds before clearing the message.
"""
self._status_label.setText(message)
if timeout_ms > 0:
QTimer.singleShot(timeout_ms, lambda: self._status_label.setText(""))
if __name__ == "__main__": # pragma: no cover
import sys
from bec_lib.messages import (
DeploymentInfoMessage,
MessagingConfig,
MessagingServiceScopeConfig,
SciLogServiceInfo,
SessionInfoMessage,
SignalServiceInfo,
TeamsServiceInfo,
)
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("dark")
# ── Build a realistic mock DeploymentInfoMessage ──────────────────
mock_deployment = DeploymentInfoMessage(
deployment_id="dep-0001",
name="mockup-beamline",
messaging_config=MessagingConfig(
signal=MessagingServiceScopeConfig(enabled=True),
teams=MessagingServiceScopeConfig(enabled=True),
scilog=MessagingServiceScopeConfig(enabled=True),
),
messaging_services=[
SciLogServiceInfo(
id="sl-global-1",
scope="beamline",
enabled=True,
name="Beamline Log",
logbook_id="lb-99001",
),
TeamsServiceInfo(
id="teams-global-1",
scope="beamline",
enabled=True,
name="BEC Channel",
workflow_webhook_url="https://outlook.office.com/webhook/…",
),
SignalServiceInfo(
id="signal-global-1",
scope="beamline",
enabled=False,
name=None,
group_id=None,
group_link=None,
),
],
active_session=SessionInfoMessage(
name="session-2026-03-07",
messaging_services=[
SciLogServiceInfo(
id="sl-local-1",
scope="experiment",
enabled=True,
name="My Notebook",
logbook_id="lb-12345",
),
SignalServiceInfo(
id="signal-local-1",
scope="experiment",
enabled=True,
name="Lab Signal Group",
group_id="grp-8a3f291c",
group_link="https://signal.group/#grp-8a3f291c",
),
],
),
)
widget = BECMessagingConfigWidget()
widget.populate_from_deployment(mock_deployment)
widget.show()
sys.exit(app.exec())

View File

@@ -1,429 +0,0 @@
"""Module for service scope cards used by the messaging configuration widget."""
from __future__ import annotations
import uuid
from enum import IntEnum
from typing import TYPE_CHECKING, Literal, Type
from bec_qthemes import material_icon
from qtpy.QtCore import QRegularExpression, Qt, QTimer, Signal # type: ignore[attr-defined]
from qtpy.QtGui import QRegularExpressionValidator
from qtpy.QtWidgets import (
QCheckBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QSizePolicy,
QSpacerItem,
QStackedLayout,
QToolButton,
QVBoxLayout,
QWidget,
)
if TYPE_CHECKING: # pragma: no cover
from bec_lib import messages
CardType = Literal["scilog", "signal", "teams"]
class ScopeListWidget(QScrollArea):
"""A scrollable list that stacks scope cards neatly at the top."""
cards_changed = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWidgetResizable(True)
self.setFrameShape(QFrame.Shape.NoFrame)
self._container = QWidget()
self._layout = QVBoxLayout(self._container)
self._layout.setContentsMargins(4, 8, 4, 8)
self._layout.setSpacing(16)
self._spacer = QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self._layout.addSpacerItem(self._spacer)
self.setWidget(self._container)
def add_card(self, card: BaseScopeCard) -> None:
"""Insert a card above the trailing spacer.
Args:
card (BaseScopeCard): The card widget to add to the list.
"""
idx = self._layout.count() - 1
self._layout.insertWidget(idx, card)
card.delete_requested.connect(lambda: self._remove_card(card))
card.delete_requested.connect(self.cards_changed)
card.data_changed.connect(self.cards_changed)
self.cards_changed.emit()
def clear_cards(self) -> None:
"""Remove all cards without touching the trailing spacer."""
for index in range(self._layout.count() - 2, -1, -1):
item = self._layout.itemAt(index)
if item is None:
continue
card = item.widget()
if isinstance(card, BaseScopeCard):
self._layout.removeWidget(card)
card.deleteLater()
self.cards_changed.emit()
def cards(self) -> list[BaseScopeCard]:
"""Return the cards currently stored in the list."""
results: list[BaseScopeCard] = []
for index in range(self._layout.count()):
item = self._layout.itemAt(index)
if item is None:
continue
card = item.widget()
if isinstance(card, BaseScopeCard):
results.append(card)
return results
def _remove_card(self, card: BaseScopeCard) -> None:
self._layout.removeWidget(card)
card.deleteLater()
self.cards_changed.emit()
class BaseScopeCard(QFrame):
"""Base card with shared identity, scope, and enabled fields."""
delete_requested = Signal()
data_changed = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._id: str = str(uuid.uuid4())
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setFrameShadow(QFrame.Shadow.Raised)
self.setStyleSheet(
"BaseScopeCard {"
" border: 1px solid palette(mid);"
" border-radius: 6px;"
" background: palette(base);"
"}"
)
root = QVBoxLayout(self)
root.setContentsMargins(20, 16, 20, 20)
root.setSpacing(14)
header_row = QHBoxLayout()
header_row.setSpacing(10)
self.enabled_checkbox = QCheckBox("Enabled")
self.enabled_checkbox.setChecked(True)
self.enabled_checkbox.toggled.connect(self.data_changed)
header_row.addWidget(self.enabled_checkbox)
header_row.addStretch(1)
self._delete_btn = QToolButton()
delete_icon = material_icon(
"delete", size=(25, 25), convert_to_pixmap=False, filled=False, color="#CC181E"
)
self._delete_btn.setToolTip("Delete this scope configuration")
self._delete_btn.setIcon(delete_icon)
self._delete_btn.clicked.connect(self.delete_requested)
header_row.addWidget(self._delete_btn)
root.addLayout(header_row)
identity_row = QHBoxLayout()
identity_row.setSpacing(16)
scope_col = QVBoxLayout()
scope_col.setSpacing(4)
scope_col.addWidget(QLabel("Scope"))
self.scope_edit = QLineEdit()
self.scope_edit.setPlaceholderText("e.g. user, admin")
self.scope_edit.textChanged.connect(self.data_changed)
scope_col.addWidget(self.scope_edit)
identity_row.addLayout(scope_col, 1)
name_col = QVBoxLayout()
name_col.setSpacing(4)
name_col.addWidget(QLabel("Name (optional)"))
self.name_edit = QLineEdit()
self.name_edit.setPlaceholderText("display name")
self.name_edit.textChanged.connect(self.data_changed)
name_col.addWidget(self.name_edit)
identity_row.addLayout(name_col, 1)
root.addLayout(identity_row)
self.content_layout = QVBoxLayout()
self.content_layout.setContentsMargins(0, 0, 0, 0)
self.content_layout.setSpacing(12)
root.addLayout(self.content_layout)
def get_data(self) -> dict:
"""Return the common payload for a messaging service card."""
return {
"id": self._id,
"scope": self.scope_edit.text(),
"enabled": self.enabled_checkbox.isChecked(),
"name": self.name_edit.text() or None,
}
def set_data(self, info: messages.MessagingService) -> None: # type: ignore[name-defined]
"""Populate the shared card fields from a messaging service.
Args:
info (messages.MessagingService): The service object used to populate the card.
"""
self._id = info.id
self.scope_edit.setText(info.scope)
self.enabled_checkbox.setChecked(info.enabled)
self.name_edit.setText(info.name or "")
class SciLogScopeCard(BaseScopeCard):
"""Card used to configure SciLog service settings."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
col = QVBoxLayout()
col.setSpacing(4)
col.addWidget(QLabel("Logbook ID"))
self.logbook_id_edit = QLineEdit()
self.logbook_id_edit.setPlaceholderText("e.g. lb-12345")
self.logbook_id_edit.textChanged.connect(self.data_changed)
col.addWidget(self.logbook_id_edit)
self.content_layout.addLayout(col)
def get_data(self) -> dict:
"""Return the SciLog-specific payload for this card."""
data = super().get_data()
data["service_type"] = "scilog"
data["logbook_id"] = self.logbook_id_edit.text()
return data
def set_data(self, info: messages.SciLogServiceInfo) -> None: # type: ignore[override]
"""Populate the card from SciLog service information.
Args:
info (messages.SciLogServiceInfo): The SciLog service object used to populate the card.
"""
super().set_data(info)
self.logbook_id_edit.setText(info.logbook_id)
class TeamsScopeCard(BaseScopeCard):
"""Card used to configure MS Teams service settings."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
fields_row = QHBoxLayout()
fields_row.setSpacing(16)
col = QVBoxLayout()
col.setSpacing(4)
col.addWidget(QLabel("Workflow Webhook URL"))
self.workflow_webhook_url_edit = edit = QLineEdit(parent=self)
edit.setPlaceholderText("e.g. https://outlook.office.com/webhook/…")
edit.textChanged.connect(self.data_changed)
col.addWidget(edit)
fields_row.addLayout(col, 1)
self.content_layout.addLayout(fields_row)
def get_data(self) -> dict:
"""Return the MS Teams-specific payload for this card."""
data = super().get_data()
data["service_type"] = "teams"
data["workflow_webhook_url"] = self.workflow_webhook_url_edit.text()
return data
def set_data(self, info: messages.TeamsServiceInfo) -> None: # type: ignore[override]
"""Populate the card from MS Teams service information.
Args:
info (messages.TeamsServiceInfo): The MS Teams service object used to populate the card.
"""
super().set_data(info)
self.workflow_webhook_url_edit.setText(info.workflow_webhook_url)
class _SignalState(IntEnum):
UNCONFIGURED = 0
PENDING = 1
CONFIGURED = 2
class SignalScopeCard(BaseScopeCard):
"""Card used to configure Signal service settings and linking state."""
_MOCK_GROUP_ID = "grp-8a3f291c"
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._state = _SignalState.UNCONFIGURED
self._mock_group_id: str = ""
self._mock_group_link: str = ""
stacked_container = QWidget()
self._stacked = QStackedLayout(stacked_container)
self._stacked.setContentsMargins(0, 0, 0, 0)
self.content_layout.addWidget(stacked_container)
self._build_unconfigured_page()
self._build_pending_page()
self._build_configured_page()
self._stacked.setCurrentIndex(_SignalState.UNCONFIGURED)
def _build_unconfigured_page(self) -> None:
page = QWidget()
row = QHBoxLayout(page)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(6)
phone_col = QVBoxLayout()
phone_col.setSpacing(4)
phone_col.addWidget(QLabel("Phone Number"))
self._phone_edit = QLineEdit()
self._phone_edit.setValidator(
QRegularExpressionValidator(QRegularExpression(r"^\+\S*$"), self._phone_edit)
)
self._phone_edit.setPlaceholderText("+41791234567")
self._phone_edit.textChanged.connect(self.data_changed)
phone_col.addWidget(self._phone_edit)
row.addLayout(phone_col, 1)
start_linking_btn = QPushButton("Start Linking")
start_linking_btn.setFixedWidth(100)
start_linking_btn.clicked.connect(self._on_ping_clicked)
row.addWidget(start_linking_btn, 0, Qt.AlignmentFlag.AlignBottom)
self._stacked.addWidget(page)
def _build_pending_page(self) -> None:
page = QWidget()
row = QHBoxLayout(page)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(6)
waiting_lbl = QLabel("⏳ Waiting for you to reply on Signal…")
waiting_lbl.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
row.addWidget(waiting_lbl)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self._on_cancel_clicked)
row.addWidget(cancel_btn)
self._stacked.addWidget(page)
def _build_configured_page(self) -> None:
page = QWidget()
row = QHBoxLayout(page)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(6)
self._linked_lbl = QLabel("🟢 Linked (Group ID: —)")
self._linked_lbl.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
row.addWidget(self._linked_lbl)
unlink_btn = QPushButton("Unlink")
unlink_btn.clicked.connect(self._on_unlink_clicked)
row.addWidget(unlink_btn)
self._stacked.addWidget(page)
def _on_ping_clicked(self) -> None:
self._state = _SignalState.PENDING
self._stacked.setCurrentIndex(_SignalState.PENDING)
QTimer.singleShot(3000, self._mock_backend_confirmation)
def _mock_backend_confirmation(self) -> None:
if self._state != _SignalState.PENDING:
return
self._mock_group_id = self._MOCK_GROUP_ID
self._mock_group_link = f"https://signal.group/#{self._mock_group_id}"
self._linked_lbl.setText(f"🟢 Linked (Group ID: {self._mock_group_id})")
self._state = _SignalState.CONFIGURED
self._stacked.setCurrentIndex(_SignalState.CONFIGURED)
self.data_changed.emit()
def _on_cancel_clicked(self) -> None:
self._state = _SignalState.UNCONFIGURED
self._stacked.setCurrentIndex(_SignalState.UNCONFIGURED)
self.data_changed.emit()
def _on_unlink_clicked(self) -> None:
self._mock_group_id = ""
self._mock_group_link = ""
self._state = _SignalState.UNCONFIGURED
self._stacked.setCurrentIndex(_SignalState.UNCONFIGURED)
self.data_changed.emit()
def get_data(self) -> dict:
"""Return the Signal-specific payload for this card."""
data = super().get_data()
data["service_type"] = "signal"
configured = self._state == _SignalState.CONFIGURED
data["group_id"] = self._mock_group_id if configured else None
data["group_link"] = self._mock_group_link if configured else None
return data
def set_data(self, info: messages.SignalServiceInfo) -> None: # type: ignore[override]
"""Populate the card from Signal service information.
Args:
info (messages.SignalServiceInfo): The Signal service object used to populate the card.
"""
super().set_data(info)
if info.group_id:
self._mock_group_id = info.group_id
self._mock_group_link = info.group_link or ""
self._linked_lbl.setText(f"🟢 Linked (Group ID: {self._mock_group_id})")
self._state = _SignalState.CONFIGURED
self._stacked.setCurrentIndex(_SignalState.CONFIGURED)
return
self._mock_group_id = ""
self._mock_group_link = ""
self._state = _SignalState.UNCONFIGURED
self._stacked.setCurrentIndex(_SignalState.UNCONFIGURED)
_CARD_CLASSES: dict[CardType, Type[BaseScopeCard]] = {
"scilog": SciLogScopeCard,
"signal": SignalScopeCard,
"teams": TeamsScopeCard,
}
def make_card(card_type: CardType) -> BaseScopeCard:
"""Create a new service card for the requested card type.
Args:
card_type (CardType): The service type for the card to create.
"""
return _CARD_CLASSES[card_type]()
def card_from_service(info: object) -> BaseScopeCard:
"""Create and populate a card from a messaging service object.
Args:
info (object): A messaging service object with a ``service_type`` attribute.
"""
service_type: str = getattr(info, "service_type", "")
card_class = _CARD_CLASSES.get(service_type) # type: ignore[arg-type]
if card_class is None:
raise ValueError(f"Unknown service_type: {service_type!r}")
card = card_class()
card.set_data(info) # type: ignore[arg-type]
return card

View File

@@ -1,125 +0,0 @@
"""Module for the service scope event subscription table widget."""
from __future__ import annotations
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QAbstractItemView,
QCheckBox,
QHBoxLayout,
QHeaderView,
QSizePolicy,
QTableWidget,
QVBoxLayout,
QWidget,
)
class ServiceScopeEventTableWidget(QWidget):
"""Widget that manages per-scope event subscriptions for messaging services."""
EVENT_NAMES = ("new_scan", "scan_finished", "alarm")
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._services: list[dict] = []
self._subscriptions: dict[str, dict[str, bool]] = {}
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
self._table = QTableWidget(len(self.EVENT_NAMES), 0, self)
self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self._table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self._table.setAlternatingRowColors(True)
self._table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._table.setVerticalHeaderLabels(list(self.EVENT_NAMES))
self._table.horizontalHeader().setStretchLastSection(True)
header = self._table.horizontalHeader()
header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self._table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
root.addWidget(self._table, 1)
def set_services(self, services: list[dict]) -> None:
"""Update the table rows to match the current services.
Args:
services (list[dict]): Service dictionaries collected from the service configuration panels.
"""
self._services = [dict(service) for service in services]
known_ids = {str(service.get("id", "")) for service in self._services if service.get("id")}
self._subscriptions = {
service_id: subscriptions
for service_id, subscriptions in self._subscriptions.items()
if service_id in known_ids
}
self._table.clearContents()
self._table.setRowCount(len(self.EVENT_NAMES))
self._table.setColumnCount(len(self._services))
self._table.setHorizontalHeaderLabels(
[self._format_service_label(service) for service in self._services]
)
for column, service in enumerate(self._services):
service_id = str(service.get("id", ""))
event_states = self._subscriptions.setdefault(
service_id, {event_name: False for event_name in self.EVENT_NAMES}
)
for row, event_name in enumerate(self.EVENT_NAMES):
self._table.setCellWidget(
row,
column,
self._make_checkbox_cell(
service_id, event_name, event_states.get(event_name, False)
),
)
def get_data(self) -> list[dict]:
"""Return the event subscriptions for the current services."""
results: list[dict] = []
for service in self._services:
service_id = str(service.get("id", ""))
results.append(
{
"id": service_id,
"source": service.get("source"),
"service_type": service.get("service_type"),
"scope": service.get("scope"),
"events": dict(
self._subscriptions.get(
service_id, {event_name: False for event_name in self.EVENT_NAMES}
)
),
}
)
return results
def _format_service_label(self, service: dict) -> str:
service_name = str(service.get("service_type", ""))
scope_name = str(service.get("scope", ""))
source_name = str(service.get("source", ""))
return f"{service_name}\n{scope_name}\n({source_name})"
def _make_checkbox_cell(self, service_id: str, event_name: str, checked: bool) -> QWidget:
checkbox = QCheckBox()
checkbox.setChecked(checked)
checkbox.toggled.connect(
lambda state, current_service_id=service_id, current_event_name=event_name: self._set_event_state(
current_service_id, current_event_name, state
)
)
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(checkbox)
return container
def _set_event_state(self, service_id: str, event_name: str, checked: bool) -> None:
self._subscriptions.setdefault(service_id, {})[event_name] = checked

View File

@@ -88,7 +88,7 @@ class DeviceBrowser(BECWidget, QWidget):
self.setLayout(layout)
def init_warning_label(self):
self.ui.scan_running_warning.setText("Warning: editing diabled while scan is running!")
self.ui.scan_running_warning.setText("Warning: editing disabled while scan is running!")
self.ui.scan_running_warning.setStyleSheet(
"background-color: #fcba03; color: rgb(0, 0, 0);"
)

View File

@@ -160,8 +160,8 @@ class ScanHistoryMetadataViewer(BECWidget, QtWidgets.QGroupBox):
Clear the view by resetting the labels and values.
"""
layout = self.layout()
lauout_counts = layout.count()
for i in range(lauout_counts):
layout_counts = layout.count()
for i in range(layout_counts):
item = layout.itemAt(i)
if item.widget():
item.widget().close()

View File

@@ -305,7 +305,7 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
def remove_scan(self, index: int):
"""
Remove a scan entry from the tree widget.
We supoprt negative indexing where -1, -2, etc.
We support negative indexing where -1, -2, etc.
Args:
index (int): The index of the scan entry to remove.

View File

@@ -31,7 +31,7 @@ api_reference/api_reference.md
## Introduction
An introduction into the single-resposibility principle and the modular design of BEC Widgets.
An introduction into the single-responsibility principle and the modular design of BEC Widgets.
```
```{grid-item-card}

View File

@@ -19,7 +19,7 @@ cd bec_widgets
```
**Install in Editable Mode**:
Please install the package in editable mode into your BEC Python environemnt.
Please install the package in editable mode into your BEC Python environment.
```bash
pip install -e '.[dev,pyside6]'
```

View File

@@ -16,7 +16,7 @@ that the widgets are discoverable.
- make sure that the widget class inherits from both `BECWidget` as well as `QWidget` or a subclass
of it, such as `QComboBox` or `QLineEdit`.
- make sure it initialises each of these superclasses in its `__init__()` method, and passes the
`parent` keyword argumment on to `QWidget.__init__()`.
`parent` keyword argument on to `QWidget.__init__()`.
- add `PLUGIN = True` as a class variable to the widget class
- add `USER_ACCESS = [...]`, including any methods and properties which should be accessible in the
client to the list, as strings.

View File

@@ -17,7 +17,7 @@
````
````{tab} Examples - CLI
In the following examples, we will use `BECIPythonClient` as the main object to interact with the `BECDockArea`. These tutorials focus on how to work with the `BECDockArea` framework, such as adding and removing docks, saving and restoring layouts, and managing the docked widgets. By default the `BECDockArea` is refered as `gui` in `BECIPythonClient`. For more detailed examples of each individual component, please refer to the example sections of each individual [`widget`](user.widgets).
In the following examples, we will use `BECIPythonClient` as the main object to interact with the `BECDockArea`. These tutorials focus on how to work with the `BECDockArea` framework, such as adding and removing docks, saving and restoring layouts, and managing the docked widgets. By default the `BECDockArea` is referred to as `gui` in `BECIPythonClient`. For more detailed examples of each individual component, please refer to the example sections of each individual [`widget`](user.widgets).
## Example 1 - Adding Docks to BECDockArea
@@ -62,7 +62,7 @@ dock_area.waveform_dock
dock_area.motor_dock
dock_area.image_dock
# If objects were closed, we will keep a refernce that will indicate that the dock was deleted
# If objects were closed, we will keep a reference that will indicate that the dock was deleted
# Try closing the window with the dock_area via mouse click on x
dock_area

View File

@@ -79,7 +79,7 @@ if __name__ == "__main__":
````
````{tab} Examples - BEC desginer
````{tab} Examples - BEC designer
The various properties can also be set when the SignalLabel widget is added to a UI in BEC designer:
```{figure} ./designer_screenshot.png

View File

@@ -215,7 +215,7 @@ Display custom text or HTML content.
Display website content.
```
```{grid-item-card} Toogle Widget
```{grid-item-card} Toggle Widget
:link: user.widgets.toggle
:link-type: ref
:img-top: /assets/widget_screenshots/toggle.png
@@ -244,7 +244,7 @@ Modern progress bar for BEC.
:link-type: ref
:img-top: /assets/widget_screenshots/position_indicator.png
Display position of motor withing its limits.
Display position of motor within its limits.
```
```{grid-item-card} LMFit Dialog

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "3.3.0"
version = "3.4.2"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [

View File

@@ -32,8 +32,8 @@ def threads_check_fixture(threads_check):
@pytest.fixture
def gui_id():
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturbate"""
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturbate
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturb"""
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturb
@pytest.fixture(scope="function")

View File

@@ -22,7 +22,7 @@ from bec_widgets.cli.client_utils import BECGuiClient
@pytest.fixture(scope="module")
def gui_id():
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturbate"""
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturb"""
return f"figure_{random.randint(0,100)}"

View File

@@ -0,0 +1,156 @@
from unittest.mock import MagicMock
import numpy as np
import pyqtgraph as pg
from bec_widgets.widgets.plots.waveform.utils.alignment_controller import (
AlignmentContext,
WaveformAlignmentController,
)
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
from .client_mocks import mocked_client
from .conftest import create_widget
from .test_waveform import make_alignment_fit_summary
def create_alignment_controller(qtbot, mocked_client):
plot_widget = pg.PlotWidget()
qtbot.addWidget(plot_widget)
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
controller = WaveformAlignmentController(plot_widget.plotItem, panel, parent=plot_widget)
return plot_widget, panel, controller
def test_alignment_controller_shows_marker_only_when_visible(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
controller.update_context(
AlignmentContext(visible=False, positioner_name="samx", precision=3, readback=1.0)
)
controller.update_position(4.2)
assert controller.marker_line is None
controller.update_context(
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=4.2)
)
assert controller.marker_line is not None
assert np.isclose(controller.marker_line.value(), 4.2)
assert panel.target_toggle.isEnabled() is True
controller.update_context(AlignmentContext(visible=False, positioner_name="samx"))
assert controller.marker_line is None
def test_alignment_controller_target_line_uses_readback_and_limits(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=(0.0, 2.0), readback=5.0
)
)
panel.target_toggle.setChecked(True)
assert controller.target_line is not None
assert np.isclose(controller.target_line.value(), 2.0)
panel.target_toggle.setChecked(False)
assert controller.target_line is None
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=None, readback=5.0
)
)
panel.target_toggle.setChecked(True)
assert controller.target_line is not None
assert np.isclose(controller.target_line.value(), 5.0)
def test_alignment_controller_preserves_dragged_target_on_context_refresh(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=1.0
)
)
panel.target_toggle.setChecked(True)
controller.target_line.setValue(3.0)
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=2.0
)
)
assert controller.target_line is not None
assert np.isclose(controller.target_line.value(), 3.0)
def test_alignment_controller_emits_move_request_for_fit_center(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
move_callback = MagicMock()
controller.move_absolute_requested.connect(move_callback)
controller.update_context(
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
)
controller.update_dap_summary(make_alignment_fit_summary(center=2.5), {"curve_id": "fit"})
assert panel.fit_dialog.action_buttons["center"].isEnabled() is True
panel.fit_dialog.action_buttons["center"].click()
move_callback.assert_called_once_with(2.5)
def test_alignment_controller_requests_autoscale_for_marker_and_target(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
autoscale_callback = MagicMock()
controller.autoscale_requested.connect(autoscale_callback)
controller.update_context(
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
)
panel.target_toggle.setChecked(True)
assert autoscale_callback.call_count >= 2
def test_alignment_controller_emits_move_request_for_target(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
move_callback = MagicMock()
controller.move_absolute_requested.connect(move_callback)
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=1.0
)
)
panel.target_toggle.setChecked(True)
controller.target_line.setValue(1.25)
panel.move_to_target_button.click()
move_callback.assert_called_once_with(1.25)
def test_alignment_controller_removes_deleted_dap_curve(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
controller.update_context(
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
)
controller.update_dap_summary(make_alignment_fit_summary(center=1.5), {"curve_id": "fit"})
assert "fit" in panel.fit_dialog.summary_data
controller.remove_dap_curve("fit")
assert "fit" not in panel.fit_dialog.summary_data
assert panel.fit_dialog.fit_curve_id is None
assert panel.fit_dialog.enable_actions is False

View File

@@ -0,0 +1,40 @@
from unittest.mock import MagicMock
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
from .client_mocks import mocked_client
from .conftest import create_widget
def test_alignment_panel_forwards_position_and_target_signals(qtbot, mocked_client):
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
position_callback = MagicMock()
target_toggle_callback = MagicMock()
target_move_callback = MagicMock()
panel.position_readback_changed.connect(position_callback)
panel.target_toggled.connect(target_toggle_callback)
panel.target_move_requested.connect(target_move_callback)
panel.positioner.position_update.emit(1.25)
panel.target_toggle.setChecked(True)
panel.move_to_target_button.setEnabled(True)
panel.move_to_target_button.click()
position_callback.assert_called_once_with(1.25)
target_toggle_callback.assert_called_once_with(True)
target_move_callback.assert_called_once_with()
def test_alignment_panel_emits_only_center_fit_actions(qtbot, mocked_client):
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
fit_center_callback = MagicMock()
panel.fit_center_requested.connect(fit_center_callback)
panel.fit_dialog.move_action.emit(("sigma", 0.5))
fit_center_callback.assert_not_called()
panel.fit_dialog.move_action.emit(("center", 2.5))
fit_center_callback.assert_called_once_with(2.5)

View File

@@ -81,12 +81,95 @@ class _FakeReply:
self.deleted = True
@pytest.fixture
def experiment_info_message() -> ExperimentInfoMessage:
data = {
"_id": "p22622",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22622"],
"realm_id": "TestBeamline",
"proposal": "12345967",
"title": "Test Experiment for Mat Card Widget",
"firstname": "John",
"lastname": "Doe",
"email": "john.doe@psi.ch",
"account": "doe_j",
"pi_firstname": "Jane",
"pi_lastname": "Smith",
"pi_email": "jane.smith@psi.ch",
"pi_account": "smith_j",
"eaccount": "e22622",
"pgroup": "p22622",
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
"proposal_submitted": "15/12/2024",
"proposal_expire": "31/12/2025",
"proposal_status": "Scheduled",
"delta_last_schedule": 30,
"mainproposal": "",
}
return ExperimentInfoMessage.model_validate(data)
@pytest.fixture
def experiment_info_list(experiment_info_message: ExperimentInfoMessage) -> list[dict]:
"""Fixture to provide a list of experiment info dictionaries."""
another_experiment_info = {
"_id": "p22623",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22623"],
"realm_id": "TestBeamline",
"proposal": "",
"title": "Experiment without Proposal",
"firstname": "Alice",
"lastname": "Johnson",
"email": "alice.johnson@psi.ch",
"account": "johnson_a",
"pi_firstname": "Bob",
"pi_lastname": "Brown",
"pi_email": "bob.brown@psi.ch",
"pi_account": "brown_b",
"eaccount": "e22623",
"pgroup": "p22623",
"abstract": "",
"schedule": [],
"proposal_submitted": "",
"proposal_expire": "",
"proposal_status": "",
"delta_last_schedule": None,
"mainproposal": "",
}
return [
experiment_info_message.model_dump(),
ExperimentInfoMessage.model_validate(another_experiment_info).model_dump(),
]
class TestBECAtlasHTTPService:
@pytest.fixture
def http_service(self, qtbot):
def deployment_info(
self, experiment_info_message: ExperimentInfoMessage
) -> DeploymentInfoMessage:
"""Fixture to provide a DeploymentInfoMessage instance."""
return DeploymentInfoMessage(
deployment_id="dep-1",
name="Test Deployment",
messaging_config=MessagingConfig(
signal=MessagingServiceScopeConfig(enabled=False),
teams=MessagingServiceScopeConfig(enabled=False),
scilog=MessagingServiceScopeConfig(enabled=False),
),
active_session=SessionInfoMessage(
experiment=experiment_info_message, name="Test Session"
),
)
@pytest.fixture
def http_service(self, deployment_info: DeploymentInfoMessage, qtbot):
"""Fixture to create a BECAtlasHTTPService instance."""
service = BECAtlasHTTPService(base_url="http://localhost:8000")
service._set_current_deployment_info(deployment_info)
qtbot.addWidget(service)
qtbot.waitExposed(service)
return service
@@ -224,7 +307,7 @@ class TestBECAtlasHTTPService:
assert http_service.auth_user_info.groups == {"operators", "staff"}
mock_get_deployment_info.assert_called_once_with(deployment_id="dep-1")
def test_handle_response_deployment_info(self, http_service, qtbot):
def test_handle_response_deployment_info(self, http_service: BECAtlasHTTPService, qtbot):
"""Test handling deployment info response"""
# Groups match: should emit authenticated signal with user info
@@ -268,6 +351,25 @@ class TestBECAtlasHTTPService:
mock_show_warning.assert_called_once()
mock_logout.assert_called_once()
def test_handle_response_deployment_info_admin_access(self, http_service, qtbot):
http_service._auth_user_info = AuthenticatedUserInfo(
email="alice@example.org",
exp=time.time() + 60,
groups={"operators"},
deployment_id="dep-1",
)
# Admin user should authenticate regardless of group membership
reply = _FakeReply(
request_url="http://localhost:8000/deployments/id?deployment_id=dep-1",
status=200,
payload=b'{"owner_groups": ["admin", "atlas_func_account"], "name": "Beamline Deployment"}',
)
with qtbot.waitSignal(http_service.authenticated, timeout=1000) as blocker:
http_service._handle_response(reply)
assert blocker.args[0]["email"] == "alice@example.org"
def test_handle_response_emits_http_response(self, http_service, qtbot):
"""Test that _handle_response emits the http_response signal with correct parameters for a generic response."""
reply = _FakeReply(
@@ -297,70 +399,6 @@ class TestBECAtlasHTTPService:
http_service._handle_response(reply, _override_slot_params={"raise_error": True})
@pytest.fixture
def experiment_info_message() -> ExperimentInfoMessage:
data = {
"_id": "p22622",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22622"],
"realm_id": "TestBeamline",
"proposal": "12345967",
"title": "Test Experiment for Mat Card Widget",
"firstname": "John",
"lastname": "Doe",
"email": "john.doe@psi.ch",
"account": "doe_j",
"pi_firstname": "Jane",
"pi_lastname": "Smith",
"pi_email": "jane.smith@psi.ch",
"pi_account": "smith_j",
"eaccount": "e22622",
"pgroup": "p22622",
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
"proposal_submitted": "15/12/2024",
"proposal_expire": "31/12/2025",
"proposal_status": "Scheduled",
"delta_last_schedule": 30,
"mainproposal": "",
}
return ExperimentInfoMessage.model_validate(data)
@pytest.fixture
def experiment_info_list(experiment_info_message: ExperimentInfoMessage) -> list[dict]:
"""Fixture to provide a list of experiment info dictionaries."""
another_experiment_info = {
"_id": "p22623",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22623"],
"realm_id": "TestBeamline",
"proposal": "",
"title": "Experiment without Proposal",
"firstname": "Alice",
"lastname": "Johnson",
"email": "alice.johnson@psi.ch",
"account": "johnson_a",
"pi_firstname": "Bob",
"pi_lastname": "Brown",
"pi_email": "bob.brown@psi.ch",
"pi_account": "brown_b",
"eaccount": "e22623",
"pgroup": "p22623",
"abstract": "",
"schedule": [],
"proposal_submitted": "",
"proposal_expire": "",
"proposal_status": "",
"delta_last_schedule": None,
"mainproposal": "",
}
return [
experiment_info_message.model_dump(),
ExperimentInfoMessage.model_validate(another_experiment_info).model_dump(),
]
class TestBECAtlasExperimentSelection:
def test_format_name(self, experiment_info_message: ExperimentInfoMessage):
@@ -546,7 +584,7 @@ class TestBECAtlasAdminView:
def test_init_and_login(self, admin_view: BECAtlasAdminView, qtbot):
"""Test that the BECAtlasAdminView initializes correctly."""
# Check that the atlas URL is set correctly
assert admin_view._atlas_url == "https://bec-atlas-dev.psi.ch/api/v1"
assert admin_view._atlas_url == "https://bec-atlas-prod.psi.ch/api/v1"
# Test that clicking the login button emits the credentials_entered signal with the correct username and password
with mock.patch.object(admin_view.atlas_http_service, "login") as mock_login:

View File

@@ -17,6 +17,8 @@ def dap_combobox(qtbot, mocked_client):
def test_dap_combobox_init(dap_combobox):
"""Test DapComboBox init."""
assert dap_combobox.fit_model_combobox is dap_combobox
assert dap_combobox.isEditable() is True
assert dap_combobox.fit_model_combobox.currentText() == "GaussianModel"
assert dap_combobox.available_models == ["GaussianModel", "LorentzModel", "SineModel"]
assert dap_combobox._validate_dap_model("GaussianModel") is True
@@ -30,7 +32,7 @@ def test_dap_combobox_set_axis(dap_combobox):
container = []
def my_callback(msg: str):
"""Calback function to store the messages."""
"""Callback function to store the messages."""
container.append(msg)
dap_combobox.x_axis_updated.connect(my_callback)
@@ -49,7 +51,7 @@ def test_dap_combobox_select_fit(dap_combobox):
container = []
def my_callback(msg: str):
"""Calback function to store the messages."""
"""Callback function to store the messages."""
container.append(msg)
dap_combobox.fit_model_updated.connect(my_callback)
@@ -64,10 +66,32 @@ def test_dap_combobox_currentTextchanged(dap_combobox):
container = []
def my_callback(msg: str):
"""Calback function to store the messages."""
"""Callback function to store the messages."""
container.append(msg)
assert dap_combobox.fit_model_combobox.currentText() == "GaussianModel"
dap_combobox.fit_model_updated.connect(my_callback)
dap_combobox.fit_model_combobox.setCurrentText("SineModel")
assert container[0] == "SineModel"
def test_dap_combobox_init_without_available_models(qtbot, mocked_client):
mocked_client.dap._available_dap_plugins = {}
widget = create_widget(qtbot, DapComboBox, client=mocked_client)
assert widget.available_models == []
assert widget.fit_model_combobox.count() == 0
assert widget.fit_model_combobox.currentText() == ""
def test_dap_combobox_invalid_manual_entry_highlighted(dap_combobox):
dap_combobox.setCurrentText("not-a-model")
assert dap_combobox.is_valid_input is False
assert "red" in dap_combobox.styleSheet()
dap_combobox.setCurrentText("GaussianModel")
assert dap_combobox.is_valid_input is True
assert "transparent" in dap_combobox.styleSheet()

View File

@@ -1422,7 +1422,7 @@ class TestDeviceConfigTemplate:
qtbot.waitExposed(template)
yield template
def test_device_config_teamplate_default_init(
def test_device_config_template_default_init(
self, device_config_template: DeviceConfigTemplate, qtbot
):
"""Test DeviceConfigTemplate default initialization."""

View File

@@ -113,7 +113,7 @@ def test_client_generator_with_black_formatting():
class _WidgetsEnumType(str, enum.Enum):
"""Enum for the available widgets, to be generated programatically"""
"""Enum for the available widgets, to be generated programmatically"""
...

View File

@@ -140,7 +140,7 @@ def lmfit_message():
def test_fit_curve_id(lmfit_dialog):
"""Test hide_curve_selection property"""
"""Test fit_curve_id property and selected_fit signal"""
my_callback = mock.MagicMock()
lmfit_dialog.selected_fit.connect(my_callback)
assert lmfit_dialog.fit_curve_id is None
@@ -148,6 +148,10 @@ def test_fit_curve_id(lmfit_dialog):
assert lmfit_dialog.fit_curve_id == "test_curve_id"
assert my_callback.call_count == 1
assert my_callback.call_args == mock.call("test_curve_id")
# Setting to None should not emit selected_fit
lmfit_dialog.fit_curve_id = None
assert lmfit_dialog.fit_curve_id is None
assert my_callback.call_count == 1
def test_remove_dap_data(lmfit_dialog):
@@ -166,6 +170,35 @@ def test_remove_dap_data(lmfit_dialog):
assert lmfit_dialog.ui.curve_list.count() == 1
def test_remove_dap_data_selected_curve_switches_to_next(lmfit_dialog):
"""Removing the currently selected curve should switch to the next available one"""
my_callback = mock.MagicMock()
lmfit_dialog.selected_fit.connect(my_callback)
lmfit_dialog.summary_data = {"curve_a": "data_a", "curve_b": "data_b"}
lmfit_dialog.fit_curve_id = "curve_a"
my_callback.reset_mock()
lmfit_dialog.remove_dap_data("curve_a")
assert lmfit_dialog.fit_curve_id == "curve_b"
assert my_callback.call_count == 1
assert my_callback.call_args == mock.call("curve_b")
def test_remove_dap_data_selected_curve_clears_when_last(lmfit_dialog):
"""Removing the only/last selected curve should clear the selection without emitting"""
my_callback = mock.MagicMock()
lmfit_dialog.selected_fit.connect(my_callback)
lmfit_dialog.summary_data = {"curve_a": "data_a"}
lmfit_dialog.fit_curve_id = "curve_a"
my_callback.reset_mock()
lmfit_dialog.remove_dap_data("curve_a")
assert lmfit_dialog.fit_curve_id is None
assert my_callback.call_count == 0
def test_update_summary_tree(lmfit_dialog, lmfit_message):
"""Test display_fit_details method"""
lmfit_dialog.active_action_list = ["center", "amplitude"]
@@ -182,3 +215,18 @@ def test_update_summary_tree(lmfit_dialog, lmfit_message):
assert lmfit_dialog.ui.param_tree.topLevelItemCount() == 4
assert lmfit_dialog.ui.param_tree.topLevelItem(0).text(0) == "amplitude"
assert lmfit_dialog.ui.param_tree.topLevelItem(0).text(1) == "1.582"
def test_compact_ui_hides_curve_selection_and_keeps_action_column(
qtbot, mocked_client, lmfit_message
):
dialog = create_widget(
qtbot, LMFitDialog, client=mocked_client, ui_file="lmfit_dialog_compact.ui"
)
dialog.hide_curve_selection = True
dialog.active_action_list = ["center"]
dialog.update_summary_tree(data=lmfit_message, metadata={"curve_id": "test_curve_id"})
assert dialog.ui.group_curve_selection.isHidden()
assert dialog.ui.param_tree.columnCount() == 4
assert "center" in dialog.action_buttons

View File

@@ -36,11 +36,11 @@ class PositionerWithoutPrecision(Positioner):
def positioner_box(qtbot, mocked_client):
"""Fixture for PositionerBox widget"""
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.uuid.uuid4"
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.uuid.uuid4"
) as mock_uuid:
mock_uuid.return_value = "fake_uuid"
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.PositionerBoxBase._check_device_is_valid",
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.PositionerBoxBase._check_device_is_valid",
return_value=True,
):
db = create_widget(qtbot, PositionerBox, device="samx", client=mocked_client)
@@ -141,7 +141,7 @@ def test_positioner_control_line(qtbot, mocked_client):
Inherits from PositionerBox, but the layout is changed. Check dimensions only
"""
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.uuid.uuid4"
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.uuid.uuid4"
) as mock_uuid:
mock_uuid.return_value = "fake_uuid"
with mock.patch(
@@ -151,7 +151,8 @@ def test_positioner_control_line(qtbot, mocked_client):
db = PositionerControlLine(device="samx", client=mocked_client)
qtbot.addWidget(db)
assert db.ui.device_box.height() == 60
assert db.ui.device_box.height() == db.height()
assert db.ui.device_box.height() >= db.dimensions[0]
assert db.ui.device_box.width() == 600

View File

@@ -12,11 +12,11 @@ from .conftest import create_widget
def positioner_box_2d(qtbot, mocked_client):
"""Fixture for PositionerBox widget"""
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.uuid.uuid4"
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.uuid.uuid4"
) as mock_uuid:
mock_uuid.return_value = "fake_uuid"
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.PositionerBoxBase._check_device_is_valid",
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.PositionerBoxBase._check_device_is_valid",
return_value=True,
):
db = create_widget(

View File

@@ -1,14 +1,18 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import json
from unittest.mock import MagicMock
import pytest
from bec_lib.endpoints import MessageEndpoints
from pydantic import ValidationError
from qtpy.QtGui import QColor
from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
from qtpy.QtGui import QColor, QMouseEvent
from qtpy.QtWidgets import QApplication
from bec_widgets.utils import Colors
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import (
RingProgressBar,
RingProgressContainerWidget,
)
from .client_mocks import mocked_client
@@ -432,8 +436,6 @@ def test_gap_affects_ring_positioning(ring_progress_bar):
for _ in range(3):
ring_progress_bar.add_ring()
initial_gap = ring_progress_bar.gap
# Change gap
new_gap = 30
ring_progress_bar.set_gap(new_gap)
@@ -467,3 +469,275 @@ def test_rings_property_returns_correct_list(ring_progress_bar):
# Should return the same list
assert rings_via_property is rings_direct
assert len(rings_via_property) == 3
###################################
# Hover behavior tests
###################################
@pytest.fixture
def container(qtbot):
widget = RingProgressContainerWidget()
qtbot.addWidget(widget)
widget.resize(200, 200)
yield widget
def _ring_center_pos(container):
"""Return (center_x, center_y, base_radius) for a square container."""
size = min(container.width(), container.height())
center_x = container.width() / 2
center_y = container.height() / 2
max_ring_size = container.get_max_ring_size()
base_radius = (size - 2 * max_ring_size) / 2
return center_x, center_y, base_radius
def _send_mouse_move(widget, pos: QPoint):
global_pos = widget.mapToGlobal(pos)
event = QMouseEvent(
QEvent.Type.MouseMove,
QPointF(pos),
QPointF(global_pos),
Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
QApplication.sendEvent(widget, event)
def test_ring_at_pos_no_rings(container):
assert container._ring_at_pos(QPointF(100, 100)) is None
def test_ring_at_pos_center_is_inside_rings(container):
"""The center of the widget is inside all rings; _ring_at_pos should return None."""
container.add_ring()
assert container._ring_at_pos(QPointF(100, 100)) is None
def test_ring_at_pos_on_single_ring(container):
"""A point on the ring arc should resolve to that ring."""
ring = container.add_ring()
cx, cy, base_radius = _ring_center_pos(container)
# Point exactly on the ring centerline
pos = QPointF(cx + base_radius, cy)
assert container._ring_at_pos(pos) is ring
def test_ring_at_pos_outside_all_rings(container):
"""A point well outside the outermost ring returns None."""
container.add_ring()
cx, cy, base_radius = _ring_center_pos(container)
line_width = container.rings[0].config.line_width
# Place point clearly beyond the outer edge
pos = QPointF(cx + base_radius + line_width + 5, cy)
assert container._ring_at_pos(pos) is None
def test_ring_at_pos_selects_correct_ring_from_multiple(qtbot):
"""With multiple rings, each position resolves to the right ring."""
container = RingProgressContainerWidget()
qtbot.addWidget(container)
container.resize(300, 300)
ring0 = container.add_ring()
ring1 = container.add_ring()
size = min(container.width(), container.height())
cx = container.width() / 2
cy = container.height() / 2
max_ring_size = container.get_max_ring_size()
base_radius = (size - 2 * max_ring_size) / 2
radius0 = base_radius - ring0.gap
radius1 = base_radius - ring1.gap
assert container._ring_at_pos(QPointF(cx + radius0, cy)) is ring0
assert container._ring_at_pos(QPointF(cx + radius1, cy)) is ring1
def test_set_hovered_ring_sets_flag(container):
"""_set_hovered_ring marks the ring as hovered and updates _hovered_ring."""
ring = container.add_ring()
assert container._hovered_ring is None
container._set_hovered_ring(ring)
assert container._hovered_ring is ring
assert ring._hovered is True
def test_set_hovered_ring_to_none_clears_flag(container):
"""Calling _set_hovered_ring(None) un-hovers the current ring."""
ring = container.add_ring()
container._set_hovered_ring(ring)
container._set_hovered_ring(None)
assert container._hovered_ring is None
assert ring._hovered is False
def test_set_hovered_ring_switches_between_rings(qtbot):
"""Switching hover from one ring to another correctly updates both flags."""
container = RingProgressContainerWidget()
qtbot.addWidget(container)
ring0 = container.add_ring()
ring1 = container.add_ring()
container._set_hovered_ring(ring0)
assert ring0._hovered is True
assert ring1._hovered is False
container._set_hovered_ring(ring1)
assert ring0._hovered is False
assert ring1._hovered is True
assert container._hovered_ring is ring1
def test_build_tooltip_text_manual_mode(container):
"""Manual mode tooltip contains mode label, value, max and percentage."""
ring = container.add_ring()
ring.set_value(50)
ring.set_min_max_values(0, 100)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Manual" in text
assert "50.0%" in text
assert "100" in text
def test_build_tooltip_text_scan_mode(container):
"""Scan mode tooltip labels the mode as 'Scan progress'."""
ring = container.add_ring()
ring.config.mode = "scan"
ring.set_value(25)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Scan progress" in text
def test_build_tooltip_text_device_mode_with_signal(container):
"""Device mode tooltip shows device:signal when both are set."""
ring = container.add_ring()
ring.config.mode = "device"
ring.config.device = "samx"
ring.config.signal = "readback"
ring.set_value(10)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Device" in text
assert "samx:readback" in text
def test_build_tooltip_text_device_mode_without_signal(container):
"""Device mode tooltip shows only device name when signal is absent."""
ring = container.add_ring()
ring.config.mode = "device"
ring.config.device = "samy"
ring.config.signal = None
ring.set_value(10)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "samy" in text
assert ":" not in text.split("Device:")[-1].split("\n")[0]
def test_build_tooltip_text_nonzero_min_shows_range(container):
"""Tooltip includes Range line when min_value is not 0."""
ring = container.add_ring()
ring.set_min_max_values(10, 90)
ring.set_value(50)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Range" in text
def test_build_tooltip_text_zero_min_no_range(container):
"""Tooltip omits Range line when min_value is 0."""
ring = container.add_ring()
ring.set_min_max_values(0, 100)
ring.set_value(50)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Range" not in text
def test_refresh_hover_tooltip_updates_label_on_value_change(container):
"""refresh_hover_tooltip updates the label text after the ring value changes."""
ring = container.add_ring()
ring.set_value(30)
container._hovered_ring = ring
container._last_hover_global_pos = QPoint(100, 100)
container.refresh_hover_tooltip(ring)
text_before = container._hover_tooltip_label.text()
ring.set_value(70)
container.refresh_hover_tooltip(ring)
text_after = container._hover_tooltip_label.text()
assert text_before != text_after
assert "70" in text_after
def test_refresh_hover_tooltip_no_pos_does_not_crash(container):
"""refresh_hover_tooltip with no stored position should return without raising."""
ring = container.add_ring()
container._last_hover_global_pos = None
# Should not raise
container.refresh_hover_tooltip(ring)
def test_mouse_move_sets_hovered_ring_and_updates_tooltip(qtbot, container):
ring = container.add_ring()
container._hover_tooltip.show_near = MagicMock()
container.show()
qtbot.waitExposed(container)
cx, cy, base_radius = _ring_center_pos(container)
_send_mouse_move(container, QPoint(int(cx + base_radius), int(cy)))
assert container._hovered_ring is ring
assert ring._hovered is True
assert "Mode: Manual" in container._hover_tooltip_label.text()
container._hover_tooltip.show_near.assert_called_once()
def test_mouse_move_switches_hover_between_rings(qtbot):
container = RingProgressContainerWidget()
qtbot.addWidget(container)
container.resize(300, 300)
ring0 = container.add_ring()
ring1 = container.add_ring()
container._hover_tooltip.show_near = MagicMock()
container.show()
qtbot.waitExposed(container)
cx, cy, base_radius = _ring_center_pos(container)
radius0 = base_radius - ring0.gap
radius1 = base_radius - ring1.gap
_send_mouse_move(container, QPoint(int(cx + radius0), int(cy)))
assert container._hovered_ring is ring0
_send_mouse_move(container, QPoint(int(cx + radius1), int(cy)))
assert container._hovered_ring is ring1
assert ring0._hovered is False
assert ring1._hovered is True
def test_leave_event_clears_hover_and_hides_tooltip(qtbot, container):
ring = container.add_ring()
container._hover_tooltip.hide = MagicMock()
container.show()
qtbot.waitExposed(container)
cx, cy, base_radius = _ring_center_pos(container)
_send_mouse_move(container, QPoint(int(cx + base_radius), int(cy)))
QApplication.sendEvent(container, QEvent(QEvent.Type.Leave))
assert container._hovered_ring is None
assert ring._hovered is False
assert container._last_hover_global_pos is None
container._hover_tooltip.hide.assert_called()

View File

@@ -240,6 +240,36 @@ def test_set_start_angle(ring_widget):
assert ring_widget.config.start_position == 180
def test_set_hovered_updates_animation_target(ring_widget):
ring_widget.set_hovered(True)
assert ring_widget._hovered is True
assert ring_widget._hover_animation.endValue() == 1.0
ring_widget.set_hovered(False)
assert ring_widget._hovered is False
assert ring_widget._hover_animation.endValue() == 0.0
def test_refresh_hover_tooltip_delegates_to_container(ring_widget):
ring_widget.progress_container = MagicMock()
ring_widget.progress_container.is_ring_hovered.return_value = True
ring_widget._request_update()
ring_widget.progress_container.refresh_hover_tooltip.assert_called_once_with(ring_widget)
def test_refresh_hover_tooltip_skips_when_ring_is_not_hovered(ring_widget):
ring_widget.progress_container = MagicMock()
ring_widget.progress_container.is_ring_hovered.return_value = False
ring_widget._request_update()
ring_widget.progress_container.refresh_hover_tooltip.assert_not_called()
###################################
# Color management tests
###################################

View File

@@ -113,7 +113,7 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
)
def fill_commponents(components: dict[str, DynamicFormItem]):
def fill_components(components: dict[str, DynamicFormItem]):
components["sample_name"].setValue("test name")
components["str_optional"].setValue(None)
components["str_required"].setValue("something")
@@ -147,7 +147,7 @@ def test_griditems_are_correct_class(
def test_grid_to_dict(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]):
widget, components = metadata_widget = metadata_widget
fill_commponents(components)
fill_components(components)
assert widget._dict_from_grid() == TEST_DICT
assert widget.get_form_data() == TEST_DICT | {"extra_field": "extra_data"}
@@ -159,7 +159,7 @@ def test_validation(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormIt
widget._validity.compact_status.default_led[:114]
)
fill_commponents(components)
fill_components(components)
widget.validate_form()
assert widget._validity_message.text() == "No errors!"
@@ -178,7 +178,7 @@ def test_numbers_clipped_to_limits(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
widget, components = metadata_widget = metadata_widget
fill_commponents(components)
fill_components(components)
components["decimal_dp_limits_nodefault"].setValue(-56)
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(1.01)

View File

@@ -36,6 +36,22 @@ from .conftest import create_widget
##################################################
def make_alignment_fit_summary(center: float | None = None) -> dict:
params = []
if center is not None:
params.append(["center", center, True, None, -np.inf, np.inf, None, 0.1, {}, 0.0, None])
params.append(["sigma", 0.5, True, None, 0.0, np.inf, None, 0.1, {}, 1.0, None])
return {
"model": "Model(test)",
"method": "leastsq",
"chisqr": 1.0,
"redchi": 1.0,
"rsquared": 0.99,
"message": "Fit succeeded.",
"params": params,
}
def test_waveform_initialization(qtbot, mocked_client):
"""
Test that a new Waveform widget initializes with the correct defaults.
@@ -496,6 +512,218 @@ def test_add_dap_curve_custom_source(qtbot, mocked_client_with_dap):
assert dap_curve.config.signal.dap == "GaussianModel"
def test_alignment_mode_toggle_shows_bottom_panel(qtbot, mocked_client):
wf = create_widget(qtbot, Waveform, client=mocked_client)
action = wf.toolbar.components.get_action("alignment_mode").action
action.trigger()
assert wf._alignment_panel_visible is True
assert wf._alignment_side_panel.panel_visible is True
assert action.isChecked() is True
action.trigger()
assert wf._alignment_panel_visible is False
assert wf._alignment_side_panel.panel_visible is False
assert action.isChecked() is False
def test_resolve_alignment_positioner(qtbot, mocked_client):
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.x_mode = "samx"
assert wf._resolve_alignment_positioner() == "samx"
wf.x_mode = "auto"
wf._current_x_device = ("samx", "samx")
assert wf._resolve_alignment_positioner() == "samx"
wf._current_x_device = ("bpm4i", "bpm4i")
assert wf._resolve_alignment_positioner() is None
wf.x_mode = "index"
assert wf._resolve_alignment_positioner() is None
wf.x_mode = "timestamp"
assert wf._resolve_alignment_positioner() is None
def test_alignment_panel_updates_when_auto_x_motor_changes(
qtbot, mocked_client_with_dap, monkeypatch
):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i", dap="GaussianModel")
wf.x_mode = "auto"
wf.toolbar.components.get_action("alignment_mode").action.trigger()
wf._current_x_device = ("samx", "samx")
wf._alignment_panel.set_positioner_device("samx")
wf.scan_item = create_dummy_scan_item()
wf.scan_item.metadata["bec"]["scan_report_devices"] = ["samy"]
data = {
"samy": {"samy": {"val": np.array([1.0, 2.0, 3.0])}},
"bpm4i": {"bpm4i": {"val": np.array([10.0, 20.0, 30.0])}},
}
monkeypatch.setattr(wf, "_fetch_scan_data_and_access", lambda: (data, "val"))
wf._get_x_data("bpm4i", "bpm4i")
assert wf._current_x_device == ("samy", "samy")
assert wf._alignment_positioner_name == "samy"
assert wf._alignment_panel.positioner.device == "samy"
def test_alignment_panel_disables_without_positioner(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i")
wf.x_mode = "index"
wf.toolbar.components.get_action("alignment_mode").action.trigger()
assert wf._alignment_panel.positioner.isEnabled() is False
assert "positioner on the x axis" in wf._alignment_panel.status_label.text()
def test_alignment_marker_updates_from_positioner_readback(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i", dap="GaussianModel")
wf.x_mode = "samx"
wf.toolbar.components.get_action("alignment_mode").action.trigger()
wf.dev["samx"].signals["samx"]["value"] = 4.2
wf._alignment_panel.positioner.force_update_readback()
assert wf._alignment_controller is not None
assert wf._alignment_controller.marker_line is not None
assert np.isclose(wf._alignment_controller.marker_line.value(), 4.2)
assert "samx" in wf._alignment_controller.marker_line.label.toPlainText()
assert "4.200" in wf._alignment_controller.marker_line.label.toPlainText()
def test_alignment_panel_uses_existing_dap_curves_and_moves_positioner(
qtbot, mocked_client_with_dap
):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
source_curve = wf.plot(arg1="bpm4i")
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
wf.x_mode = "samx"
wf.toolbar.components.get_action("alignment_mode").action.trigger()
fit_summary = make_alignment_fit_summary(center=2.5)
wf.dap_summary_update.emit(fit_summary, {"curve_id": dap_curve.name()})
wf._alignment_panel.fit_dialog.select_curve(dap_curve.name())
move_spy = MagicMock()
wf.dev["samx"].move = move_spy
assert wf._alignment_panel.fit_dialog.fit_curve_id == dap_curve.name()
assert wf._alignment_panel.fit_dialog.action_buttons["center"].isEnabled() is True
wf._alignment_panel.fit_dialog.action_buttons["center"].click()
move_spy.assert_called_once_with(2.5, relative=False)
def test_alignment_target_line_toggle_updates_target_value_label(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i", dap="GaussianModel")
wf.x_mode = "samx"
wf.toolbar.components.get_action("alignment_mode").action.trigger()
wf._alignment_panel.target_toggle.setChecked(True)
assert wf._alignment_controller is not None
assert wf._alignment_controller.target_line is not None
assert wf._alignment_panel.move_to_target_button.isEnabled() is True
wf._alignment_controller.target_line.setValue(1.5)
assert "1.500" in wf._alignment_panel.target_toggle.text()
def test_alignment_move_to_target_uses_draggable_line_value(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i", dap="GaussianModel")
wf.x_mode = "samx"
wf.toolbar.components.get_action("alignment_mode").action.trigger()
wf._alignment_panel.target_toggle.setChecked(True)
wf._alignment_controller.target_line.setValue(1.25)
move_spy = MagicMock()
wf.dev["samx"].move = move_spy
wf._alignment_panel.move_to_target_button.click()
move_spy.assert_called_once_with(1.25, relative=False)
def test_alignment_mode_toggle_off_keeps_user_dap_curve(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
source_curve = wf.plot(arg1="bpm4i")
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
wf.x_mode = "samx"
action = wf.toolbar.components.get_action("alignment_mode").action
action.trigger()
action.trigger()
assert wf.get_curve(dap_curve.name()) is not None
def test_alignment_mode_toggle_off_clears_controller_overlays(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i", dap="GaussianModel")
wf.x_mode = "samx"
action = wf.toolbar.components.get_action("alignment_mode").action
action.trigger()
wf._alignment_panel.target_toggle.setChecked(True)
wf.dev["samx"].signals["samx"]["value"] = 2.0
wf._alignment_panel.positioner.force_update_readback()
assert wf._alignment_controller.marker_line is not None
assert wf._alignment_controller.target_line is not None
action.trigger()
assert wf._alignment_controller.marker_line is None
assert wf._alignment_controller.target_line is None
def test_alignment_panel_removes_deleted_dap_curve_from_fit_list(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
source_curve = wf.plot(arg1="bpm4i")
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
wf.toolbar.components.get_action("alignment_mode").action.trigger()
wf.dap_summary_update.emit(
make_alignment_fit_summary(center=1.5), {"curve_id": dap_curve.name()}
)
assert dap_curve.name() in wf._alignment_panel.fit_dialog.summary_data
wf.remove_curve(dap_curve.name())
assert dap_curve.name() not in wf._alignment_panel.fit_dialog.summary_data
def test_alignment_controller_move_request_moves_positioner(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i", dap="GaussianModel")
wf.x_mode = "samx"
move_spy = MagicMock()
wf.dev["samx"].move = move_spy
wf.toolbar.components.get_action("alignment_mode").action.trigger()
wf._alignment_controller.move_absolute_requested.emit(3.5)
move_spy.assert_called_once_with(3.5, relative=False)
def test_curve_set_data_emits_dap_update(qtbot, mocked_client):
wf = create_widget(qtbot, Waveform, client=mocked_client)
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="test_curve")
@@ -637,7 +865,7 @@ def test_fetch_scan_data_and_access(qtbot, mocked_client, monkeypatch):
wf._fetch_scan_data_and_access()
hist_mock.assert_called_once_with(-1)
# Ckeck live mode
# Check live mode
dummy_scan = create_dummy_scan_item()
wf.scan_item = dummy_scan
data_dict, access_key = wf._fetch_scan_data_and_access()