Compare commits

...

28 Commits

Author SHA1 Message Date
semantic-release d2cbd84479 3.15.0
Automatically generated by python-semantic-release
2026-06-12 13:53:19 +00:00
wyzula_j 3dfed232ef fix(pydantic): adoption to new ScanArgument refactor from bec 2026-06-12 15:52:32 +02:00
wyzula_j 64ed28ba4f refactor(notification_banner): remove defensive patterns 2026-06-12 15:52:32 +02:00
wyzula_j 434f9f561f fix(widget_it): device/signal combobox handler 2026-06-12 15:52:32 +02:00
wyzula_j 768c138576 feat(beamline-states): collapse all functionality with cleanup of not used settings widgets if state is not dirty 2026-06-12 15:52:32 +02:00
wyzula_j 9550866b67 build(bec): bump bec_lib and bec_ipython_client to v3.134 2026-06-12 15:52:32 +02:00
wyzula_j 64cbf93d64 fix(beamline-states): better pydantic model handling 2026-06-12 15:52:32 +02:00
wyzula_j 4bb7e811dd refactor(beamline-states): BeamlineStateManager widget moved to separate module 2026-06-12 15:52:32 +02:00
wyzula_j 08650e86a3 fix(bec_widget): removal of non existing TYPE check for old dock area 2026-06-12 15:52:32 +02:00
wyzula_j 563603b80e feat(forms): unified pydantic and scan control adapter for pydantic models 2026-06-12 15:52:32 +02:00
wyzula_j d07d03c1be fix(notification-banner): eventFilter guard for QStandartItem 2026-06-12 15:52:32 +02:00
wyzula_j 6aa1f7e74a feat(dock-area): expose beamline state manager 2026-06-12 15:52:32 +02:00
wyzula_j 2546cc484d feat(beamline-states): add state manager widget 2026-06-12 15:52:32 +02:00
wyzula_j b20897f4bf feat(forms): add pydantic widget form 2026-06-12 15:52:32 +02:00
wyzula_j 7e6dca4912 feat(widget_io): register handler 2026-06-12 15:52:32 +02:00
wyzula_j f6f590cabd refactor(main-window): remove status-tip override 2026-06-12 15:52:32 +02:00
wyzula_j f78bc26a26 fix(notification-center): sync light theme styling 2026-06-12 15:52:32 +02:00
wyzula_j aca2c4a7a5 refactor(colors): consolidate theme helpers 2026-06-12 15:52:32 +02:00
wyzula_j 6db198e684 fix(device-input): align validity styling 2026-06-12 15:52:32 +02:00
semantic-release 6f3ee6316b 3.14.0
Automatically generated by python-semantic-release
2026-06-11 14:23:59 +00:00
wyzula_j 3d93cf2f01 fix(progress): scan progress reset on_scan_status in unified backend 2026-06-11 16:22:30 +02:00
wyzula_j e547ec71ae refactor(bec_progress): simplification of chunk radius calculation 2026-06-11 16:22:30 +02:00
wyzula_j e8bd80377e fix(ring): ProgressSignal fetch logic back 2026-06-11 16:22:30 +02:00
wyzula_j e8e67f68a2 fix(scan_control): remove parent from layout to prevent QLayout: Attempting to add QLayout "" to ScanGroupBox "", which already has a layout 2026-06-11 16:22:30 +02:00
wyzula_j 51f7652b1f feat(progress): progress is tracked from bec; unified progress backend 2026-06-11 16:22:30 +02:00
wyzula_j 007f9306a6 fix(bec_progress_bar): replace the custom paint event progressbar with native QProgressBar 2026-06-11 16:22:30 +02:00
wyzula_j acfc1b4b88 ci(child_repos): artifact logs upload if child pipelines fail 2026-06-11 15:51:05 +02:00
wyzula_j af125e2222 test(e2e): increase rpc test_available_widgets timout back to 100 2026-06-02 14:51:33 +02:00
47 changed files with 5366 additions and 1046 deletions
+21
View File
@@ -45,6 +45,18 @@ jobs:
cd ./bec
pip install pytest pytest-random-order
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./bec_server/tests ./bec_ipython_client/tests/client_tests ./bec_lib/tests
- name: Upload BEC unit test artifacts if job fails
if: failure()
uses: actions/upload-artifact@v4
with:
name: bec-unit-test-artifacts
path: |
./bec/report.xml
./bec/logs/*.log
if-no-files-found: ignore
retention-days: 7
bec-e2e-test:
name: BEC End2End Tests
runs-on: ubuntu-latest
@@ -62,3 +74,12 @@ jobs:
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
PYTHON_VERSION: '3.11'
- name: Upload BEC e2e logs if job fails
if: failure()
uses: actions/upload-artifact@v4
with:
name: bec-e2e-test-logs
path: ./_e2e_test_checkout_/bec/logs/*.log
if-no-files-found: ignore
retention-days: 7
+104
View File
@@ -1,6 +1,110 @@
# CHANGELOG
## v3.15.0 (2026-06-12)
### Bug Fixes
- **beamline-states**: Better pydantic model handling
([`64cbf93`](https://github.com/bec-project/bec_widgets/commit/64cbf93d64895cc8af9522506512c4e8f5de939d))
- **bec_widget**: Removal of non existing TYPE check for old dock area
([`08650e8`](https://github.com/bec-project/bec_widgets/commit/08650e86a3d3aa1098514bad3d8119401ace1d3f))
- **device-input**: Align validity styling
([`6db198e`](https://github.com/bec-project/bec_widgets/commit/6db198e68422967c1c6d8ffb749b993e0e4975e1))
- **notification-banner**: Eventfilter guard for QStandartItem
([`d07d03c`](https://github.com/bec-project/bec_widgets/commit/d07d03c1be7b405e52126cfc507fdcd79093cb45))
- **notification-center**: Sync light theme styling
([`f78bc26`](https://github.com/bec-project/bec_widgets/commit/f78bc26a26bfebfc7df83a9c6cd7dd5c95a35d05))
- **pydantic**: Adoption to new ScanArgument refactor from bec
([`3dfed23`](https://github.com/bec-project/bec_widgets/commit/3dfed232efb8baff14ebe62b6b14e62e0a4acd36))
- **widget_it**: Device/signal combobox handler
([`434f9f5`](https://github.com/bec-project/bec_widgets/commit/434f9f561fca199aadc798db682a7e863a3246b3))
### Build System
- **bec**: Bump bec_lib and bec_ipython_client to v3.134
([`9550866`](https://github.com/bec-project/bec_widgets/commit/9550866b677bb304ab0ea490827d0d0c09064722))
### Features
- **beamline-states**: Add state manager widget
([`2546cc4`](https://github.com/bec-project/bec_widgets/commit/2546cc484d1c483fd45f1b80847a7e0200faba43))
- **beamline-states**: Collapse all functionality with cleanup of not used settings widgets if state
is not dirty
([`768c138`](https://github.com/bec-project/bec_widgets/commit/768c138576ad924dfad334888583131cc452e4f0))
- **dock-area**: Expose beamline state manager
([`6aa1f7e`](https://github.com/bec-project/bec_widgets/commit/6aa1f7e74ae4497d449b03e600e5c3fbbfc34be0))
- **forms**: Add pydantic widget form
([`b20897f`](https://github.com/bec-project/bec_widgets/commit/b20897f4bf2c05653e57bd79c5e292883d1f8ee8))
- **forms**: Unified pydantic and scan control adapter for pydantic models
([`563603b`](https://github.com/bec-project/bec_widgets/commit/563603b80e52ec6746a57fa68b1f7b2dbc101439))
- **widget_io**: Register handler
([`7e6dca4`](https://github.com/bec-project/bec_widgets/commit/7e6dca49120fba480a8756a0cb951b88a2df3584))
### Refactoring
- **beamline-states**: Beamlinestatemanager widget moved to separate module
([`4bb7e81`](https://github.com/bec-project/bec_widgets/commit/4bb7e811dd514e69e9a507505b976c3b8de1d035))
- **colors**: Consolidate theme helpers
([`aca2c4a`](https://github.com/bec-project/bec_widgets/commit/aca2c4a7a50e85b0d0f61251f71bc097a258599f))
- **main-window**: Remove status-tip override
([`f6f590c`](https://github.com/bec-project/bec_widgets/commit/f6f590cabdfd66f4bb4f8095414db5d120b0f9a1))
- **notification_banner**: Remove defensive patterns
([`64ed28b`](https://github.com/bec-project/bec_widgets/commit/64ed28ba4f554958305cc9cf37da1a124ba2a99b))
## v3.14.0 (2026-06-11)
### Bug Fixes
- **bec_progress_bar**: Replace the custom paint event progressbar with native QProgressBar
([`007f930`](https://github.com/bec-project/bec_widgets/commit/007f9306a62f60cf66a268f608984b6a954b8653))
- **progress**: Scan progress reset on_scan_status in unified backend
([`3d93cf2`](https://github.com/bec-project/bec_widgets/commit/3d93cf2f01778a9825a94adcba44a26fbeaf4be4))
- **ring**: Progresssignal fetch logic back
([`e8bd803`](https://github.com/bec-project/bec_widgets/commit/e8bd80377e0bee40142c5cd0d4a9c35d35f2d950))
- **scan_control**: Remove parent from layout to prevent `QLayout: Attempting to add QLayout "" to
ScanGroupBox "", which already has a layout`
([`e8e67f6`](https://github.com/bec-project/bec_widgets/commit/e8e67f68a2912c69a7df3d82daaa67fab3ae1139))
### Continuous Integration
- **child_repos**: Artifact logs upload if child pipelines fail
([`acfc1b4`](https://github.com/bec-project/bec_widgets/commit/acfc1b4b883b2a3cf0596c881489cb2c953dd219))
### Features
- **progress**: Progress is tracked from bec; unified progress backend
([`51f7652`](https://github.com/bec-project/bec_widgets/commit/51f7652b1fe59db6bf94a8183ae0e3a715601aa6))
### Refactoring
- **bec_progress**: Simplification of chunk radius calculation
([`e547ec7`](https://github.com/bec-project/bec_widgets/commit/e547ec71ae1f45db72d9b8cde0b5fe564466333c))
### Testing
- **e2e**: Increase rpc test_available_widgets timout back to 100
([`af125e2`](https://github.com/bec-project/bec_widgets/commit/af125e2222ff11a73f28dadb3a5d93e409ad010e))
## v3.13.5 (2026-06-02)
### Bug Fixes
+54 -1
View File
@@ -32,6 +32,7 @@ _Widgets = {
"BECQueue": "BECQueue",
"BECShell": "BECShell",
"BECStatusBox": "BECStatusBox",
"BeamlineStateManager": "BeamlineStateManager",
"BecConsole": "BecConsole",
"DapComboBox": "DapComboBox",
"DeviceBrowser": "DeviceBrowser",
@@ -427,7 +428,7 @@ class BECMainWindow(RPCBase):
class BECProgressBar(RPCBase):
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
"""A BEC progress bar backed by Qt's native QProgressBar."""
_IMPORT_MODULE = "bec_widgets.widgets.progress.bec_progressbar.bec_progressbar"
@@ -717,6 +718,58 @@ class BaseROI(RPCBase):
"""
class BeamlineStateManager(RPCBase):
"""Widget displaying and managing all BEC beamline states."""
_IMPORT_MODULE = "bec_widgets.widgets.services.beamline_states.beamline_state_manager"
@rpc_call
def clear_filters(self) -> "None":
"""
None
"""
@rpc_call
def collapse_all(self) -> "None":
"""
Collapse the settings panel of all displayed state pills.
"""
@rpc_call
def state_summary(self) -> "dict[str, dict[str, str]]":
"""
Return all beamline states (including filtered ones) with their current status and label.
Returns:
dict: Mapping of state name to a dictionary with ``status`` and ``label`` keys.
"""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
class BecConsole(RPCBase):
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
+5
View File
@@ -19,6 +19,10 @@ designer_plugins = {
"BECShell": ("bec_widgets.widgets.editors.bec_console.bec_console", "BECShell"),
"BECSpinBox": ("bec_widgets.widgets.utility.spinbox.decimal_spinbox", "BECSpinBox"),
"BECStatusBox": ("bec_widgets.widgets.services.bec_status_box.bec_status_box", "BECStatusBox"),
"BeamlineStateManager": (
"bec_widgets.widgets.services.beamline_states.beamline_state_manager",
"BeamlineStateManager",
),
"BecConsole": ("bec_widgets.widgets.editors.bec_console.bec_console", "BecConsole"),
"ColorButton": ("bec_widgets.widgets.utility.visual.color_button.color_button", "ColorButton"),
"ColorButtonNative": (
@@ -118,6 +122,7 @@ widget_icons = {
"BECShell": "hub",
"BECSpinBox": "123",
"BECStatusBox": "widgets",
"BeamlineStateManager": "format_list_bulleted",
"BecConsole": "terminal",
"ColorButton": "colors",
"ColorButtonNative": "colors",
-1
View File
@@ -20,7 +20,6 @@ from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.busy_loader import BusyLoaderOverlay
from bec_widgets.widgets.containers.dock import BECDock
logger = bec_logger.logger
+34 -65
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import re
from functools import lru_cache
from typing import Literal
from typing import Any, Literal
import numpy as np
import pyqtgraph as pg
@@ -21,8 +21,7 @@ logger = bec_logger.logger
def get_theme_name():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
return "dark"
else:
return QApplication.instance().theme.theme
return QApplication.instance().theme.theme
def get_theme_palette():
@@ -58,6 +57,25 @@ def apply_theme(theme: Literal["dark", "light"]):
process_all_deferred_deletes(QApplication.instance())
def theme_color(theme: Any | None, key: str, fallback: QColor | str) -> QColor:
"""
Return a QColor from a BEC theme, or the fallback when no theme is set.
"""
fallback_color = fallback if isinstance(fallback, QColor) else QColor(str(fallback))
if theme is None:
return fallback_color
return theme.color(key, fallback_color.name())
def rgba(color: QColor | str, alpha: int) -> str:
"""
Return a QSS-compatible rgba string.
"""
qcolor = color if isinstance(color, QColor) else QColor(str(color))
return f"rgba({qcolor.red()}, {qcolor.green()}, {qcolor.blue()}, {alpha})"
class Colors:
@staticmethod
def list_available_colormaps() -> list[str]:
@@ -150,25 +168,6 @@ class Colors:
return ge.colorMap()
@staticmethod
def golden_ratio(num: int) -> list:
"""Calculate the golden ratio for a given number of angles.
Args:
num (int): Number of angles
Returns:
list: List of angles calculated using the golden ratio.
"""
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
angles = []
for ii in range(num):
x = np.cos(ii * phi)
y = np.sin(ii * phi)
angle = np.arctan2(y, x)
angles.append(angle)
return angles
@staticmethod
def set_theme_offset(theme: Literal["light", "dark"] | None = None, offset=0.2) -> tuple:
"""
@@ -239,20 +238,7 @@ class Colors:
else:
positions = np.linspace(min_pos, max_pos, num)
# Sample colors from the colormap at the calculated positions
colors = cmap.map(positions, mode="float")
color_list = []
for color in colors:
if format.upper() == "HEX":
color_list.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
color_list.append(tuple((np.array(color) * 255).astype(int)))
elif format.upper() == "QCOLOR":
color_list.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
return color_list
return Colors._format_mapped_colors(cmap.map(positions, mode="float"), format)
@staticmethod
def golden_angle_color(
@@ -288,20 +274,19 @@ class Colors:
positions = np.mod(np.arange(num) * golden_angle_conjugate, 1)
positions = min_pos + positions * (max_pos - min_pos)
# Sample colors from the colormap at the calculated positions
colors = cmap.map(positions, mode="float")
color_list = []
return Colors._format_mapped_colors(cmap.map(positions, mode="float"), format)
for color in colors:
if format.upper() == "HEX":
color_list.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
color_list.append(tuple((np.array(color) * 255).astype(int)))
elif format.upper() == "QCOLOR":
color_list.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
return color_list
@staticmethod
def _format_mapped_colors(colors: np.ndarray, format: Literal["QColor", "HEX", "RGB"]) -> list:
color_format = format.upper()
if color_format not in {"QCOLOR", "HEX", "RGB"}:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
if color_format == "QCOLOR":
return [QColor.fromRgbF(*color) for color in colors]
if color_format == "HEX":
return [QColor.fromRgbF(*color).name() for color in colors]
return [tuple((np.array(color) * 255).astype(int)) for color in colors]
@staticmethod
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
@@ -325,22 +310,6 @@ class Colors:
raise ValueError("HEX color must be 6 or 8 characters long.")
return (r, g, b, alpha)
@staticmethod
def rgba_to_hex(r: int, g: int, b: int, a: int = 255) -> str:
"""
Convert RGBA color to HEX.
Args:
r(int): Red value (0-255).
g(int): Green value (0-255).
b(int): Blue value (0-255).
a(int): Alpha value (0-255). Default is 255 (opaque).
Returns:
hec_color(str): HEX color string.
"""
return "#{:02X}{:02X}{:02X}{:02X}".format(r, g, b, a)
@staticmethod
def validate_color(color: tuple | str) -> tuple | str:
"""
@@ -0,0 +1,53 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from bec_lib.scan_args import ScanArgument
from pydantic import BaseModel
from pydantic_core import PydanticUndefined
from bec_widgets.utils.scan_arg_metadata import ui_config_from_metadata
NUMERIC_BOUND_KEYS = {"gt", "ge", "lt", "le"}
def pydantic_model_input_configs(model: type[BaseModel]) -> list[dict[str, Any]]:
"""Return scan-control-style field items for a Pydantic model."""
configs = []
for name, info in model.model_fields.items():
metadata: dict[str, Any] = {}
for entry in info.metadata:
if isinstance(entry, ScanArgument):
metadata.update(entry.model_dump(exclude_none=True))
continue
for key in NUMERIC_BOUND_KEYS:
value = getattr(entry, key, None)
if value is not None:
metadata.setdefault(key, value)
if isinstance(info.json_schema_extra, Mapping):
metadata.update(dict(info.json_schema_extra))
if info.description and metadata.get("description") is None:
metadata["description"] = info.description
default: Any
if info.default is not PydanticUndefined:
default = info.default
elif info.default_factory is not None:
default = info.get_default(call_default_factory=True)
else:
default = None
display_name = metadata.get("display_name") or info.title
if display_name is None:
display_name = name.replace("_", " ").capitalize()
item = ui_config_from_metadata(
name=name, metadata=metadata, default=default, display_name=display_name
)
item.update({key: value for key, value in metadata.items() if key not in item})
configs.append(item)
return configs
@@ -0,0 +1,815 @@
from __future__ import annotations
from types import NoneType
from typing import Any, Literal, get_args, get_origin
from bec_lib.device import DeviceBase, Signal
from pydantic import BaseModel, ValidationError
from pydantic.fields import FieldInfo
from qtpy.QtCore import Qt
from qtpy.QtCore import Signal as QtSignal
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QLineEdit,
QSpinBox,
QWidget,
)
from bec_widgets.utils.forms_from_types.pydantic_model_info_adapter import (
NUMERIC_BOUND_KEYS,
pydantic_model_input_configs,
)
from bec_widgets.utils.scan_arg_metadata import (
apply_numeric_limits,
apply_numeric_precision,
apply_unit_metadata,
device_units,
)
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
class OptionalValueWidget(QWidget):
"""Wrap a value widget with an enable checkbox for optional Pydantic fields.
Attributes:
value_changed: Signal emitted with the current value whenever the checkbox
state or wrapped widget value changes.
"""
value_changed = QtSignal(object)
def __init__(self, value_widget: QWidget, parent: QWidget | None = None) -> None:
"""Create an optional-value wrapper.
Args:
value_widget: Input widget used when the optional value is enabled.
parent: Optional parent widget.
"""
super().__init__(parent=parent)
self._value_widget = value_widget
self._checkbox = QCheckBox(self)
self._checkbox.setToolTip("Enable value")
self._value_widget.setParent(self)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
layout.addWidget(self._checkbox)
layout.addWidget(self._value_widget, 1)
self._checkbox.toggled.connect(self._on_enabled_changed)
WidgetIO.connect_widget_change_signal(self._value_widget, self._emit_current_value)
self._on_enabled_changed(False)
@property
def value_widget(self) -> QWidget:
"""Return the wrapped input widget.
Returns:
The widget that edits the non-``None`` value.
"""
return self._value_widget
@property
def checkbox(self) -> QCheckBox:
"""Return the checkbox controlling whether the value is enabled.
Returns:
The enable checkbox.
"""
return self._checkbox
def value(self) -> Any:
"""Return the current optional value.
Returns:
``None`` when the checkbox is unchecked; otherwise the wrapped widget value.
"""
if not self._checkbox.isChecked():
return None
return WidgetIO.get_value(self._value_widget)
def set_value(self, value: Any) -> None:
"""Set the optional value.
Args:
value: Value to set on the wrapped widget. ``None`` disables the value.
"""
enabled = value is not None
self._checkbox.setChecked(enabled)
self._value_widget.setEnabled(enabled)
if enabled:
WidgetIO.set_value(self._value_widget, value)
def _on_enabled_changed(self, enabled: bool) -> None:
self._value_widget.setEnabled(enabled)
self.value_changed.emit(self.value())
def _emit_current_value(self, *_args) -> None:
self.value_changed.emit(self.value())
class PydanticWidgetForm(QWidget):
"""Generate a Qt form from a Pydantic model.
The form maps Pydantic field annotations to Qt widgets, applies supported
field metadata, and exposes typed and raw data accessors for the generated
fields.
Attributes:
changed: Signal emitted whenever a generated input widget changes.
validity_changed: Signal emitted by :meth:`validate` with the current
validation result.
"""
changed = QtSignal()
validity_changed = QtSignal(bool)
def __init__(
self,
model: type[BaseModel],
parent: QWidget | None = None,
*,
data: BaseModel | dict[str, Any] | None = None,
read_only_fields: set[str] | None = None,
client=None,
) -> None:
"""Create a generated form for a Pydantic model.
Args:
model: Pydantic model class used to generate fields and validate data.
parent: Optional parent widget.
data: Optional initial model instance or raw field-value mapping.
read_only_fields: Field names that should be displayed but not editable.
client: Optional BEC client passed to domain-specific widgets such as
device and signal combo boxes.
"""
super().__init__(parent=parent)
self._model = model
self._client = client
self._read_only_fields = set(read_only_fields or set())
self._widgets: dict[str, QWidget] = {}
self._field_configs: dict[str, dict[str, Any]] = {}
self._baseline: dict[str, Any] = {}
self._layout = QFormLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setHorizontalSpacing(10)
self._layout.setVerticalSpacing(8)
self._layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
self._layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self.setLayout(self._layout)
self._populate()
if data is not None:
self.set_data(data)
self.mark_clean()
@property
def model(self) -> type[BaseModel]:
"""Return the active Pydantic model class.
Returns:
The model class currently used by this form.
"""
return self._model
@property
def widgets(self) -> dict[str, QWidget]:
"""Return generated field widgets keyed by model field name.
Returns:
A shallow copy of the field-widget mapping. Optional fields return
their outer :class:`OptionalValueWidget`.
"""
return dict(self._widgets)
def field_widget(self, name: str) -> QWidget:
"""Return the generated widget for a field.
Args:
name: Model field name.
Returns:
The generated field widget. Optional fields return their outer
:class:`OptionalValueWidget`.
Raises:
KeyError: If no widget exists for ``name``.
"""
return self._widgets[name]
def input_widget(self, name: str) -> QWidget:
"""Return the direct input widget for a field.
Args:
name: Model field name.
Returns:
The editable input widget. Optional fields return the wrapped value
widget instead of the outer optional wrapper.
Raises:
KeyError: If no widget exists for ``name``.
"""
widget = self._widgets[name]
if isinstance(widget, OptionalValueWidget):
return widget.value_widget
return widget
def input_widgets(self) -> dict[str, QWidget]:
"""Return direct input widgets keyed by model field name.
Returns:
Mapping of field names to editable input widgets.
"""
return {name: self.input_widget(name) for name in self._widgets}
def input_widgets_by_type(self, widget_type: type[QWidget]) -> list[QWidget]:
"""Return direct input widgets matching a widget type.
Args:
widget_type: Qt widget class to match with ``isinstance``.
Returns:
List of input widgets matching ``widget_type``.
"""
return [
widget for widget in self.input_widgets().values() if isinstance(widget, widget_type)
]
def set_model(self, model: type[BaseModel], data: dict[str, Any] | None = None) -> None:
"""Replace the active model and rebuild the form.
Args:
model: New Pydantic model class.
data: Optional initial data for the new model. When omitted, values
from fields shared with the previous model are preserved.
"""
old_data = self.raw_data()
self.cleanup()
self._model = model
self._populate()
if data is None:
data = {key: value for key, value in old_data.items() if key in model.model_fields}
self.set_partial_data(data)
self.mark_clean()
def set_data(self, data: BaseModel | dict[str, Any]) -> None:
"""Set form values from a model instance or mapping.
Args:
data: Pydantic model instance or raw field-value mapping.
"""
values = data.model_dump() if isinstance(data, BaseModel) else dict(data)
self.set_partial_data(values)
def set_partial_data(self, data: dict[str, Any]) -> None:
"""Set values for fields present in the form.
Unknown keys are ignored, which allows callers to pass larger model
dumps or backend payloads safely.
Args:
data: Field-value mapping to apply.
"""
for name, value in data.items():
if name not in self._widgets:
continue
self._set_widget_value(name, value)
self._refresh_reference_units()
self.changed.emit()
def raw_data(self) -> dict[str, Any]:
"""Return current widget values without Pydantic validation.
Returns:
Mapping of model field names to raw widget values.
"""
return {name: self._read_widget_value(name) for name in self._widgets}
def get_data(self) -> dict[str, Any]:
"""Return current data after Pydantic validation.
Returns:
Validated model data as a dictionary.
Raises:
ValidationError: If Pydantic validation fails.
ValueError: If domain widget validation fails.
"""
return self.model_instance().model_dump()
def model_instance(self) -> BaseModel:
"""Return the current values as a Pydantic model instance.
Returns:
Validated instance of the active model class.
Raises:
ValidationError: If Pydantic validation fails.
ValueError: If domain widget validation fails.
"""
self._validate_domain_widgets()
return self._model.model_validate(self.raw_data())
def validate(self) -> bool:
"""Validate the current form values.
Returns:
``True`` when current values validate successfully, otherwise ``False``.
"""
try:
self.get_data()
except (ValidationError, ValueError):
self.validity_changed.emit(False)
return False
self.validity_changed.emit(True)
return True
def dirty_fields(self) -> set[str]:
"""Return fields whose raw values differ from the clean baseline.
Returns:
Set of dirty field names.
"""
current = self.raw_data()
fields = set(current) | set(self._baseline)
return {field for field in fields if current.get(field) != self._baseline.get(field)}
def mark_clean(self) -> None:
"""Store the current raw values as the clean baseline."""
self._baseline = self.raw_data()
def reset_to_baseline(self) -> None:
"""Restore the form values to the current clean baseline."""
self.set_partial_data(self._baseline)
def editable_data(self) -> dict[str, Any]:
"""Return validated data excluding read-only fields.
Returns:
Validated editable field values.
Raises:
ValidationError: If Pydantic validation fails.
ValueError: If domain widget validation fails.
"""
return {
key: value
for key, value in self.get_data().items()
if key not in self._read_only_fields
}
def raw_editable_data(self) -> dict[str, Any]:
"""Return raw widget data excluding read-only fields.
Returns:
Raw editable field values.
"""
return {
key: value
for key, value in self.raw_data().items()
if key not in self._read_only_fields
}
def cleanup(self) -> None:
"""Close and schedule deletion of all generated field widgets."""
while self._layout.rowCount():
row = self._layout.takeRow(0)
for item in (row.labelItem, row.fieldItem):
widget = item.widget() if item is not None else None
if widget is not None:
widget.close()
# Detach before deleteLater: a child pending deletion that still has a
# signal connection into this form crashes if the form is garbage
# collected before the deferred delete is processed.
widget.setParent(None)
widget.deleteLater()
self._widgets.clear()
self._field_configs.clear()
def closeEvent(self, event) -> None: # noqa: N802
self.cleanup()
super().closeEvent(event)
def _populate(self) -> None:
for config in pydantic_model_input_configs(self._model):
name = config["name"]
info = self._model.model_fields[name]
widget = self._create_widget(name, info)
label_text = config["display_name"]
self._layout.addRow(label_text, widget)
label = self._layout.labelForField(widget)
if label is not None:
label.setProperty("_model_field_name", name)
if config.get("tooltip") and label is not None:
label.setToolTip(config["tooltip"])
widget.setEnabled(name not in self._read_only_fields)
self._widgets[name] = widget
self._field_configs[name] = config
self._set_widget_value(name, config["default"])
self._apply_field_metadata(name)
self._connect_widget(widget)
self._connect_device_signal_widgets()
self._connect_reference_unit_widgets()
self._refresh_reference_units()
def _create_widget(self, name: str, info: FieldInfo) -> QWidget:
annotation = info.annotation
args = get_args(annotation)
optional = NoneType in args
non_none_args = tuple(arg for arg in args if arg is not NoneType)
value_annotation = non_none_args[0] if len(non_none_args) == 1 else annotation
widget = self._create_value_widget(name, value_annotation)
numeric = value_annotation in (int, float) or any(
arg in (int, float) for arg in get_args(value_annotation)
)
if optional and (numeric or value_annotation is bool):
return OptionalValueWidget(widget, parent=self)
return widget
def _create_value_widget(self, name: str, annotation: Any) -> QWidget:
args = get_args(annotation)
if (
isinstance(annotation, type)
and issubclass(annotation, Signal)
or any(isinstance(arg, type) and issubclass(arg, Signal) for arg in args)
):
return SignalComboBox(
parent=self,
client=self._client,
require_device=self._model_has_device_field(),
arg_name=name,
)
if (
isinstance(annotation, type)
and issubclass(annotation, DeviceBase)
or any(isinstance(arg, type) and issubclass(arg, DeviceBase) for arg in args)
):
return DeviceComboBox(parent=self, client=self._client, arg_name=name)
if get_origin(annotation) is Literal:
widget = QComboBox(self)
widget.addItems([str(value) for value in get_args(annotation)])
return widget
if annotation is bool:
return QCheckBox(self)
if annotation is int:
spin_box = QSpinBox(self)
spin_box.setRange(-2147483647, 2147483647)
return spin_box
if annotation is float:
spin_box = BECSpinBox(self)
spin_box.setRange(-1_000_000_000, 1_000_000_000)
return spin_box
return QLineEdit(self)
def _apply_field_metadata(self, name: str) -> None:
config = self._field_configs[name]
field_widget = self._widgets[name]
input_widget = self.input_widget(name)
if config.get("precision") is not None:
apply_numeric_precision(input_widget, config)
if any(config.get(key) is not None for key in NUMERIC_BOUND_KEYS):
apply_numeric_limits(input_widget, config)
apply_unit_metadata(field_widget, config)
if input_widget is not field_widget:
apply_unit_metadata(input_widget, config)
def _connect_widget(self, widget: QWidget) -> None:
if isinstance(widget, OptionalValueWidget):
widget.value_changed.connect(lambda _value: self.changed.emit())
return
WidgetIO.connect_widget_change_signal(widget, lambda *_args: self.changed.emit())
def _connect_device_signal_widgets(self) -> None:
devices = [
widget for widget in self._widgets.values() if isinstance(widget, DeviceComboBox)
]
signals = [
widget for widget in self._widgets.values() if isinstance(widget, SignalComboBox)
]
if not devices or not signals:
return
device_widget = devices[0]
for signal_widget in signals:
device_widget.device_selected.connect(signal_widget.set_device)
device_widget.device_reset.connect(lambda w=signal_widget: w.set_device(None))
if device_widget.currentText().strip():
signal_widget.set_device(device_widget.currentText().strip())
def _connect_reference_unit_widgets(self) -> None:
for name, widget in self.input_widgets().items():
if not isinstance(widget, DeviceComboBox):
continue
widget.device_selected.connect(
lambda _device_name, field_name=name: self._update_reference_units(field_name)
)
widget.device_reset.connect(
lambda field_name=name: self._apply_reference_units(field_name, None)
)
widget.currentTextChanged.connect(
lambda text, field_name=name: self._handle_reference_device_text(field_name, text)
)
def _refresh_reference_units(self) -> None:
for name, widget in self.input_widgets().items():
if isinstance(widget, DeviceComboBox):
self._update_reference_units(name)
def _update_reference_units(self, source_name: str) -> None:
widget = self.input_widget(source_name)
if not isinstance(widget, DeviceComboBox) or not widget.is_valid_input:
self._apply_reference_units(source_name, None)
return
self._apply_reference_units(source_name, device_units(widget.get_current_device()))
def _apply_reference_units(self, source_name: str, units: str | None) -> None:
for field_name, config in self._field_configs.items():
if config.get("reference_units") != source_name:
continue
field_widget = self.field_widget(field_name)
input_widget = self.input_widget(field_name)
apply_unit_metadata(field_widget, config, units)
if input_widget is not field_widget:
apply_unit_metadata(input_widget, config, units)
def _handle_reference_device_text(self, source_name: str, device_name: str) -> None:
widget = self.input_widget(source_name)
if isinstance(widget, DeviceComboBox) and not widget.validate_device(device_name):
self._apply_reference_units(source_name, None)
def _validate_domain_widgets(self) -> None:
for widget in self._widgets.values():
if isinstance(widget, DeviceComboBox):
device = widget.currentText().strip()
if not device:
raise ValueError("Device is required.")
if not widget.is_valid_input:
raise ValueError(f"Device '{device}' is not available.")
if isinstance(widget, SignalComboBox):
signal = widget.get_signal_name().strip()
if signal and not widget.is_valid_input:
raise ValueError(f"Signal '{signal}' is not available.")
def _read_widget_value(self, name: str) -> Any:
widget = self._widgets[name]
info = self._model.model_fields[name]
if isinstance(widget, OptionalValueWidget):
return widget.value()
if isinstance(widget, QLineEdit):
value = WidgetIO.get_value(widget)
return None if NoneType in get_args(info.annotation) and value == "" else value
if isinstance(widget, QComboBox) and get_origin(info.annotation) is Literal:
return WidgetIO.get_value(widget, as_string=True)
return WidgetIO.get_value(widget)
def _set_widget_value(self, name: str, value: Any) -> None:
widget = self._widgets[name]
if isinstance(widget, OptionalValueWidget):
widget.set_value(value)
return
if value is None:
if isinstance(widget, QLineEdit):
value = ""
elif isinstance(widget, QCheckBox):
value = False
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
value = 0
WidgetIO.set_value(widget, value)
def _model_has_device_field(self) -> bool:
for field in self._model.model_fields.values():
annotation = field.annotation
args = get_args(annotation)
has_device = (
isinstance(annotation, type)
and issubclass(annotation, DeviceBase)
or any(isinstance(arg, type) and issubclass(arg, DeviceBase) for arg in args)
)
has_signal = (
isinstance(annotation, type)
and issubclass(annotation, Signal)
or any(isinstance(arg, type) and issubclass(arg, Signal) for arg in args)
)
if has_device and not has_signal:
return True
return False
if __name__ == "__main__": # pragma: no cover
import json
import sys
from bec_lib.scan_args import ScanArgument
from pydantic import Field
from qtpy.QtWidgets import QApplication, QLabel, QPushButton, QTabWidget, QTextEdit, QVBoxLayout
from bec_widgets.utils.colors import apply_theme
class BasicScanConfig(BaseModel):
"""Plain Pydantic fields without GUI metadata."""
sample_name: str
enabled: bool = True
repeats: int = 3
class LimitConfig(BaseModel):
"""Normal Pydantic Field metadata."""
mode: Literal["monitor", "scan", "calibration"] = "scan"
low_limit: (
float | None
) # example of the field without additional metadata, still works in form
high_limit: float | None = Field(
default=10.0,
title="High limit",
description="Optional upper allowed value.",
json_schema_extra={"precision": 4},
)
tolerance: float = Field(
default=0.1,
title="Tolerance",
description="Warning tolerance around configured limits.",
json_schema_extra={"precision": 4},
)
class ScanArgumentConfig(BaseModel):
"""ScanArgument metadata applied through Field extras."""
settling_time: float = Field(
default=0.0,
**ScanArgument(
display_name="Settling time",
description="Time to wait after moving.",
units="s",
precision=3,
ge=0,
).model_dump(),
)
frames: int = Field(
default=1,
**ScanArgument(
display_name="Frames", description="Number of frames per trigger.", ge=1
).model_dump(),
)
class DeviceSignalLimitsConfig(BaseModel):
"""Device, signal, and numeric fields whose units follow the selected device."""
model_config = {"arbitrary_types_allowed": True}
device: DeviceBase | str = Field(
default="",
**ScanArgument(display_name="Device", description="Positioner device.").model_dump(),
)
signal: Signal | str | None = Field(
default=None,
**ScanArgument(display_name="Signal", description="Device signal.").model_dump(),
)
low_limit: float | None = Field(
default=None,
**ScanArgument(
display_name="Low limit",
description="Optional lower limit.",
reference_units="device",
precision=4,
).model_dump(),
)
high_limit: float | None = Field(
default=None,
**ScanArgument(
display_name="High limit",
description="Optional upper limit.",
reference_units="device",
precision=4,
).model_dump(),
)
class DisplayConfig(BaseModel):
title: str | None = Field(
default=None, title="Title", description="Optional display title."
)
show_grid: bool = Field(default=True, title="Show grid")
refresh_interval: int = Field(
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
)
class DeviceAndSignalConfig(BaseModel):
model_config = {"arbitrary_types_allowed": True}
title: str | None = Field(
default=None, title="Title", description="Optional display title."
)
device: DeviceBase | str = Field(
default="", title="Device", description="BEC device selection."
)
signal: Signal | str | None = Field(
default=None,
title="Signal",
description="Signal selection scoped to the selected device.",
)
refresh_interval: int = Field(
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
)
class DeviceOnlyConfig(BaseModel):
model_config = {"arbitrary_types_allowed": True}
title: str | None = Field(
default=None, title="Title", description="Optional display title."
)
device: DeviceBase | str = Field(
default="", title="Device", description="BEC device selection."
)
refresh_interval: int = Field(
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
)
class SignalOnlyConfig(BaseModel):
model_config = {"arbitrary_types_allowed": True}
title: str | None = Field(
default=None, title="Title", description="Optional display title."
)
signal: Signal | str | None = Field(
default=None,
title="Signal",
description="Global BEC signal selection without a device field.",
)
refresh_interval: int = Field(
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
)
class ExampleWindow(QWidget):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("PydanticWidgetForm example")
self._tabs = QTabWidget(self)
self._output = QTextEdit(self)
self._output.setReadOnly(True)
self._output.setPlaceholderText("Validated form data appears here.")
self._forms: list[PydanticWidgetForm] = []
self._add_form("Basic", PydanticWidgetForm(BasicScanConfig))
self._add_form("Limits", PydanticWidgetForm(LimitConfig))
self._add_form("ScanArgument", PydanticWidgetForm(ScanArgumentConfig))
self._add_form("Display", PydanticWidgetForm(DisplayConfig))
self._add_form("Device + signal", PydanticWidgetForm(DeviceAndSignalConfig))
self._add_form("Device limits", PydanticWidgetForm(DeviceSignalLimitsConfig))
self._add_form("Device only", PydanticWidgetForm(DeviceOnlyConfig))
self._add_form("Signal only", PydanticWidgetForm(SignalOnlyConfig))
show_data = QPushButton("Show current tab data", self)
show_data.clicked.connect(self._show_current_data)
layout = QVBoxLayout(self)
layout.addWidget(QLabel("Generated forms from Pydantic models", self))
layout.addWidget(self._tabs)
layout.addWidget(show_data)
layout.addWidget(self._output)
def _add_form(self, title: str, form: PydanticWidgetForm) -> None:
form.changed.connect(lambda _form=form: self._on_form_changed(_form))
self._forms.append(form)
self._tabs.addTab(form, title)
def _show_current_data(self, _checked: bool = False, *, validate: bool = True) -> None:
form = self._forms[self._tabs.currentIndex()]
if validate:
try:
data = form.get_data()
except (ValidationError, ValueError) as exc:
self._output.setPlainText(str(exc))
return
key = "data"
else:
data = form.raw_data()
key = "raw_data"
self._output.setPlainText(
json.dumps(
{key: data, "dirty_fields": sorted(form.dirty_fields())}, indent=2, default=str
)
)
def _on_form_changed(self, form: PydanticWidgetForm) -> None:
if form is self._forms[self._tabs.currentIndex()]:
self._show_current_data(validate=False)
app = QApplication(sys.argv)
apply_theme("dark")
window = ExampleWindow()
window.show()
sys.exit(app.exec())
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
from bec_lib import bec_logger
from qtpy.QtWidgets import QDoubleSpinBox, QSpinBox, QWidget
logger = bec_logger.logger
UNIT_TOOLTIP_PREFIXES = ("Units:", "Units from:")
def format_display_name(name: str) -> str:
"""Convert a raw argument name into a user-facing label."""
parts = re.split(r"(_|\d+)", name)
return " ".join(part.capitalize() for part in parts if part.isalnum()).strip()
def resolve_tooltip(scan_argument: Mapping[str, Any]) -> str | None:
"""Resolve explicit tooltip text, falling back to the description."""
return scan_argument.get("tooltip") or scan_argument.get("description")
def ui_config_from_metadata(
name: str,
metadata: Mapping[str, Any],
*,
default: Any = None,
input_type: Any = None,
arg: bool = False,
display_name: str | None = None,
) -> dict[str, Any]:
"""Build the normalized scan-input item consumed by form widgets."""
return {
"arg": arg,
"name": name,
"type": input_type,
"display_name": display_name or metadata.get("display_name") or format_display_name(name),
"tooltip": resolve_tooltip(metadata),
"default": default,
"expert": metadata.get("expert", False),
"hidden": metadata.get("hidden", False),
"precision": metadata.get("precision"),
"units": metadata.get("units"),
"reference_units": metadata.get("reference_units"),
"reference_limits": metadata.get("reference_limits"),
"gt": metadata.get("gt"),
"ge": metadata.get("ge"),
"lt": metadata.get("lt"),
"le": metadata.get("le"),
"alternative_group": metadata.get("alternative_group"),
}
def unit_tooltip(item: Mapping[str, Any], units: str | None = None) -> str | None:
"""Build tooltip text from scan argument unit metadata."""
tooltip = item.get("tooltip")
reference_units = item.get("reference_units")
units = units or item.get("units")
tooltip_parts = [tooltip] if tooltip else []
if units:
tooltip_parts.append(f"Units: {units}")
elif reference_units:
tooltip_parts.append(f"Units from: {reference_units}")
if tooltip_parts:
return "\n".join(str(part) for part in tooltip_parts)
return None
def strip_unit_tooltip(tooltip: str) -> str:
"""Remove unit lines added by :func:`apply_unit_metadata`."""
return "\n".join(
line for line in tooltip.splitlines() if not line.startswith(UNIT_TOOLTIP_PREFIXES)
).strip()
def apply_unit_metadata(widget: QWidget, item: Mapping[str, Any], units: str | None = None) -> None:
"""Apply unit tooltip text and numeric suffix metadata to a widget."""
units = units or item.get("units")
tooltip = unit_tooltip(item, units)
existing_tooltip = strip_unit_tooltip(widget.toolTip())
base_tooltip = item.get("tooltip")
if base_tooltip and existing_tooltip == base_tooltip:
existing_tooltip = ""
if tooltip:
widget.setToolTip(f"{existing_tooltip}\n{tooltip}" if existing_tooltip else tooltip)
else:
widget.setToolTip(existing_tooltip)
if hasattr(widget, "setSuffix"):
widget.setSuffix(f" {units}" if units else "")
def device_units(device: object) -> str | None:
"""Return engineering units from a BEC device object when available."""
egu = getattr(device, "egu", None)
if not callable(egu):
return None
try:
return egu()
except Exception:
logger.exception("Failed to fetch engineering units from device %s", device)
return None
def apply_numeric_precision(widget: QWidget, item: Mapping[str, Any]) -> None:
"""Apply decimal precision metadata to spinboxes supporting ``setDecimals``."""
if not hasattr(widget, "setDecimals"):
return
precision = item.get("precision")
if precision is None:
return
try:
widget.setDecimals(max(0, int(precision)))
except (TypeError, ValueError):
logger.warning(
"Ignoring invalid precision %r for parameter %s", precision, item.get("name")
)
def apply_numeric_limits(widget: QWidget, item: Mapping[str, Any]) -> None:
"""Apply ``gt/ge/lt/le`` numeric bounds to Qt spinboxes."""
if isinstance(widget, QSpinBox) and not isinstance(widget, QDoubleSpinBox):
minimum = -2147483647
maximum = 2147483647
if item.get("ge") is not None:
minimum = int(item["ge"])
if item.get("gt") is not None:
minimum = int(item["gt"]) + 1
if item.get("le") is not None:
maximum = int(item["le"])
if item.get("lt") is not None:
maximum = int(item["lt"]) - 1
widget.setRange(minimum, maximum)
return
if isinstance(widget, QDoubleSpinBox):
minimum = -float("inf")
maximum = float("inf")
step = 10 ** (-widget.decimals())
if item.get("ge") is not None:
minimum = float(item["ge"])
if item.get("gt") is not None:
minimum = float(item["gt"]) + step
if item.get("le") is not None:
maximum = float(item["le"])
if item.get("lt") is not None:
maximum = float(item["lt"]) - step
widget.setRange(minimum, maximum)
+62
View File
@@ -99,6 +99,45 @@ class ComboBoxHandler(WidgetHandler):
widget.currentIndexChanged.connect(lambda idx, w=widget: slot(w, self.get_value(w)))
class DeviceComboBoxHandler(ComboBoxHandler):
"""Handler for BEC device comboboxes. The widget value is the device name."""
def get_value(self, widget, **kwargs) -> str:
return widget.currentText().strip()
def set_value(self, widget, value: str | None) -> None:
device = "" if value is None else str(value)
if not device:
widget.setCurrentText("")
return
widget.set_device(device)
if widget.currentText() != device:
widget.setCurrentText(device)
def connect_change_signal(self, widget, slot):
widget.currentTextChanged.connect(lambda text, w=widget: slot(w, text.strip()))
class SignalComboBoxHandler(ComboBoxHandler):
"""Handler for BEC signal comboboxes. The widget value is the signal object name."""
def get_value(self, widget, **kwargs) -> str | None:
signal = widget.get_signal_name().strip()
return signal or None
def set_value(self, widget, value: str | None) -> None:
signal = "" if value is None else str(value)
if not signal:
widget.setCurrentText("")
return
widget.set_signal(signal)
if widget.currentText() != signal and widget.get_signal_name() != signal:
widget.setCurrentText(signal)
def connect_change_signal(self, widget, slot):
widget.currentTextChanged.connect(lambda _text, w=widget: slot(w, self.get_value(w)))
class TableWidgetHandler(WidgetHandler):
"""Handler for QTableWidget widgets."""
@@ -207,6 +246,28 @@ class WidgetIO:
ToggleSwitch: ToggleSwitchHandler,
QSlider: SlideHandler,
}
_deferred_handlers_registered = False
@classmethod
def _register_deferred_handlers(cls) -> None:
"""
Register handlers for widgets that import this module themselves and therefore
cannot be imported here at module level without a circular import. The import is
deferred to the first handler lookup, when all modules are fully initialized.
"""
if cls._deferred_handlers_registered:
return
cls._deferred_handlers_registered = True
# pylint: disable=import-outside-toplevel
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
DeviceComboBox,
)
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import (
SignalComboBox,
)
cls._handlers[DeviceComboBox] = DeviceComboBoxHandler
cls._handlers[SignalComboBox] = SignalComboBoxHandler
@staticmethod
def get_value(widget, ignore_errors=False, **kwargs):
@@ -282,6 +343,7 @@ class WidgetIO:
Returns:
handler_class: The handler class if found, otherwise None.
"""
WidgetIO._register_deferred_handlers()
for base in type(widget).__mro__:
if base in WidgetIO._handlers:
return WidgetIO._handlers[base]
@@ -385,6 +385,11 @@ class BECDockArea(DockAreaWidget):
"bec_shell": (widget_icons["BECShell"], "Add BEC Shell", "BECShell"),
"sbb_monitor": (widget_icons["SBBMonitor"], "Add SBB Monitor", "SBBMonitor"),
"log_panel": (widget_icons["LogPanel"], "Add LogPanel", "LogPanel"),
"beamline_state_manager": (
widget_icons["BeamlineStateManager"],
"Add Beamline State Manager",
"BeamlineStateManager",
),
}
# Create expandable menu actions (original behavior)
@@ -11,7 +11,6 @@ Intended for use in desktop applications to provide user feedback, warnings, and
from __future__ import annotations
import json
import sys
from datetime import datetime
from enum import Enum
@@ -21,6 +20,7 @@ from uuid import uuid4
import pyqtgraph as pg
from bec_lib.alarm_handler import Alarms # external enum
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import ErrorInfo
from bec_qthemes import material_icon
from qtpy import QtCore, QtGui, QtWidgets
@@ -29,9 +29,11 @@ from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidg
from bec_widgets import SafeProperty, SafeSlot
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.colors import apply_theme, get_theme_name
from bec_widgets.utils.widget_io import WidgetIO
logger = bec_logger.logger
class SeverityKind(str, Enum):
INFO = "info"
@@ -148,11 +150,14 @@ class NotificationToast(QFrame):
body_lbl.setWordWrap(True)
self.time_lbl = QtWidgets.QLabel()
self._showing_absolute = False
self._update_relative_time()
# enable absolute timestamp on hover
self.time_lbl.setCursor(QtCore.Qt.PointingHandCursor)
self.time_lbl.installEventFilter(self)
self._showing_absolute = False
# shared ID assigned by NotificationCentre.add_notification
self.notification_id: str | None = None
self.close_btn = QtWidgets.QPushButton("")
self.close_btn.setObjectName("toastCloseBtn")
@@ -245,21 +250,22 @@ class NotificationToast(QFrame):
# lifetime progress animation
self._lifetime = max(0, lifetime_ms) # 0 → never expire
self._progress_anim: QtCore.QPropertyAnimation | None = None
# flag to indicate this toast has fully expired (progress bar finished)
self._expired = False
if self._lifetime > 0:
self._start_progress_animation()
else:
self.progress.hide()
# flag to indicate this toast has fully expired (progress bar finished)
self._expired = False
# ------------------------------------------------------------------
def _connect_to_theme_change(self):
"""Connect this toast to the global themeupdated signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self.apply_theme)
if hasattr(qapp, "theme"):
qapp.theme.theme_changed.connect(self.apply_theme)
else:
logger.warning("Theme could not be fetched form QApplication object.")
# helper methods -----------------------------------------------------
def _current_inner_width(self) -> int:
@@ -333,9 +339,6 @@ class NotificationToast(QFrame):
}}
""")
self.apply_theme(self._theme)
# keep injected gradient in sync
if getattr(self, "_hg_enabled", False):
self._hg_cols[0] = self._accent_color
@SafeProperty(str)
def traceback(self):
@@ -354,11 +357,9 @@ class NotificationToast(QFrame):
Args:
theme(str | None): "light" or "dark". If None, auto-detects from QApplication.
"""
# determine effective theme
if theme is None:
app = QApplication.instance()
theme = getattr(getattr(app, "theme", None), "theme", "dark")
theme = theme.lower()
theme = str(theme or get_theme_name()).lower()
if theme not in {"light", "dark"}:
theme = "dark"
self._theme = theme
palette = DARK_PALETTE if theme == "dark" else LIGHT_PALETTE
@@ -403,11 +404,18 @@ class NotificationToast(QFrame):
#NotificationToast QPushButton:hover {{ color: {btn_hover}; }}
""")
# traceback panel colours
trace_bg = "#1e1e1e" if theme == "dark" else "#f0f0f0"
if theme == "dark": # FIXME Unify stylesheets and move them to BECQThemes issue #1189
trace_bg = "#1e1e1e"
trace_fg = palette["body"]
trace_border = "rgba(255,255,255,48)"
else:
trace_bg = "#ffffff"
trace_fg = palette["body"]
trace_border = "rgba(15,23,42,54)"
self.trace_view.setStyleSheet(f"""
background:{trace_bg};
color:{palette['body']};
border:none;
color:{trace_fg};
border: 1px solid {trace_border};
border-radius:8px;
""")
@@ -438,8 +446,8 @@ class NotificationToast(QFrame):
}}
""")
# stronger accent wash in light mode, slightly stronger in dark too
self._accent_alpha = 110 if theme == "light" else 60
self._accent_alpha = 6 if theme == "light" else 60
self._gradient_width_factor = 1.0 if theme == "light" else 0.70
self.update()
########################################
@@ -447,7 +455,7 @@ class NotificationToast(QFrame):
########################################
def _update_relative_time(self) -> None:
if getattr(self, "_showing_absolute", False):
if self._showing_absolute:
return # don't overwrite while user is viewing absolute time
seconds = int((datetime.now() - self.created).total_seconds())
if seconds < 10:
@@ -471,6 +479,8 @@ class NotificationToast(QFrame):
# Event Filters
########################################
def eventFilter(self, watched, event):
if not isinstance(event, QtCore.QEvent):
return False
# timestamp label → toggle absolute time
if watched is self.time_lbl:
if event.type() == QtCore.QEvent.Enter and not self._showing_absolute:
@@ -486,7 +496,7 @@ class NotificationToast(QFrame):
Pause the countdown while the cursor is over the toast, and reset the
elapsed time and progress bar to full width.
"""
if getattr(self, "_expired", False):
if self._expired:
return super().enterEvent(event)
self._hover = True
if self._progress_anim is not None:
@@ -500,10 +510,10 @@ class NotificationToast(QFrame):
Resume the countdown when the cursor leaves, continuing from the
paused progress rather than restarting.
"""
if getattr(self, "_expired", False):
if self._expired:
return super().leaveEvent(event)
self._hover = False
if self._lifetime > 0 and not self._expired:
if self._lifetime > 0:
self._start_progress_animation()
super().leaveEvent(event)
@@ -519,11 +529,11 @@ class NotificationToast(QFrame):
painter.fillPath(path, self._base_color)
# accent gradient, fades to transparent
grad = QtGui.QLinearGradient(0, 0, self.width() * 0.7, 0)
grad = QtGui.QLinearGradient(0, 0, self.width() * self._gradient_width_factor, 0)
accent = QtGui.QColor(self._accent_color)
if getattr(self, "_theme", "dark") == "light":
if self._theme == "light":
accent = accent.darker(115)
accent.setAlpha(getattr(self, "_accent_alpha", 50))
accent.setAlpha(self._accent_alpha)
grad.setColorAt(0.0, accent)
fade = QtGui.QColor(self._accent_color)
fade.setAlpha(0)
@@ -543,7 +553,7 @@ class NotificationToast(QFrame):
def close(self) -> None:
self.closed.emit()
QtWidgets.QApplication.instance().removeEventFilter(self)
self.time_lbl.removeEventFilter(self)
super().close() # this will remove the widget from its parent
@@ -577,8 +587,7 @@ class NotificationCentre(QScrollArea):
def __init__(self, parent=None, *, fixed_width: int = 420, margin: int = 16):
super().__init__(parent=parent)
self.setObjectName("NotificationCentre")
app = QApplication.instance()
self._theme = getattr(getattr(app, "theme", None), "theme", "dark").lower()
self._theme = get_theme_name()
self.setWidgetResizable(True)
# transparent background so only the toast cards are visible
@@ -673,8 +682,10 @@ class NotificationCentre(QScrollArea):
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self.apply_theme)
if hasattr(qapp, "theme"):
qapp.theme.theme_changed.connect(self.apply_theme)
else:
logger.warning("Theme could not be fetched form QApplication object.")
# public API
def add_notification(
@@ -741,7 +752,7 @@ class NotificationCentre(QScrollArea):
def remove_notification(self, notification_id: str) -> None:
"""Close a specific notification in this centre if present."""
for toast in list(self.toasts):
if getattr(toast, "notification_id", None) == notification_id:
if toast.notification_id == notification_id:
self._hide_notification(toast)
# ------------------------------------------------------------------
@@ -888,6 +899,8 @@ class NotificationCentre(QScrollArea):
self.setFixedHeight(min(content_h, avail))
def eventFilter(self, watched, event):
if not isinstance(event, QtCore.QEvent):
return False
if watched is self.parent() and event.type() == QtCore.QEvent.Resize:
self._adjust_height()
return super().eventFilter(watched, event)
@@ -4,7 +4,7 @@ import os
from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
from qtpy.QtCore import QSize, Qt, QTimer
from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import (
QApplication,
@@ -46,8 +46,8 @@ logger = bec_logger.logger
class BECMainWindow(BECWidget, QMainWindow):
RPC = True
PLUGIN = True
SCAN_PROGRESS_WIDTH = 100 # px
SCAN_PROGRESS_HEIGHT = 12 # px
SCAN_PROGRESS_WIDTH = 120 # px
SCAN_PROGRESS_HEIGHT = 20 # px
def __init__(self, parent=None, window_title: str = "BEC", **kwargs):
super().__init__(parent=parent, **kwargs)
@@ -197,7 +197,11 @@ class BECMainWindow(BECWidget, QMainWindow):
# Setting HoverWidget for the scan progress bar - minimal and full version
self._scan_progress_bar_simple = ScanProgressBar(
self, one_line_design=True, rpc_exposed=False, rpc_passthrough_children=False
self,
one_line_design=True,
rpc_exposed=False,
rpc_passthrough_children=False,
enable_dynamic_stylesheet=True,
)
self._scan_progress_bar_simple.show_elapsed_time = False
self._scan_progress_bar_simple.show_remaining_time = False
@@ -205,8 +209,9 @@ class BECMainWindow(BECWidget, QMainWindow):
self._scan_progress_bar_simple.progressbar.label_template = ""
self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
# This one do not need dynamic styling on hover ScanProgressBar since user will hover on it probably later, when progress bar is big enough
self._scan_progress_bar_full = ScanProgressBar(
self, rpc_exposed=False, rpc_passthrough_children=False
self, rpc_exposed=False, rpc_passthrough_children=False, enable_dynamic_stylesheet=False
)
self._scan_progress_hover = HoverWidget(
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
@@ -233,8 +238,8 @@ class BECMainWindow(BECWidget, QMainWindow):
# The actual line
line = QFrame()
line.setFrameShape(QFrame.VLine)
line.setFrameShadow(QFrame.Sunken)
line.setFrameShape(QFrame.Shape.VLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
line.setFixedHeight(status_bar.sizeHint().height() - 2)
# Wrapper to center the line vertically -> work around for QFrame not being able to center itself
@@ -242,7 +247,7 @@ class BECMainWindow(BECWidget, QMainWindow):
vbox = QVBoxLayout(wrapper)
vbox.setContentsMargins(0, 0, 0, 0)
vbox.addStretch()
vbox.addWidget(line, alignment=Qt.AlignHCenter)
vbox.addWidget(line, alignment=Qt.AlignmentFlag.AlignHCenter)
vbox.addStretch()
wrapper.setFixedWidth(line.sizeHint().width())
@@ -412,11 +417,6 @@ class BECMainWindow(BECWidget, QMainWindow):
"""
apply_theme(theme) # emits theme_updated and applies palette globally
def event(self, event):
if event.type() == QEvent.Type.StatusTip:
return True
return super().event(event)
def _show_widget_hierarchy_dialog(self):
if self._widget_hierarchy_dialog is None:
dialog = WidgetHierarchyDialog(root_widget=None, parent=self)
@@ -603,8 +603,10 @@ class DeviceComboBox(BECWidget, QComboBox):
return device.readout_priority in self.readout_filter
def _update_validity_style(self, is_valid: bool) -> None:
border_color = "transparent" if is_valid or not self.isEnabled() else "red"
self.setStyleSheet(f"border: 1px solid {border_color};")
if is_valid or not self.isEnabled():
self.setStyleSheet("")
return
self.setStyleSheet("QComboBox { border: 1px solid red; }")
def _filter_devices_by_signal_class(
self, devices: list[Device | BECSignal | ComputedSignal | Positioner]
@@ -626,8 +626,10 @@ class SignalComboBox(BECWidget, QComboBox):
self.check_validity(self.currentText())
def _update_validity_style(self, is_valid: bool) -> None:
border_color = "transparent" if is_valid or not self.isEnabled() else "red"
self.setStyleSheet(f"border: 1px solid {border_color};")
if is_valid or not self.isEnabled():
self.setStyleSheet("")
return
self.setStyleSheet("QComboBox { border: 1px solid red; }")
def _replace_signal_items(self, items: list[str | tuple[str, dict]] | None = None):
combo_items = self._signals if items is None else items
@@ -20,6 +20,12 @@ from qtpy.QtWidgets import (
QVBoxLayout,
)
from bec_widgets.utils.scan_arg_metadata import (
apply_numeric_limits,
apply_numeric_precision,
apply_unit_metadata,
device_units,
)
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
BECDeviceFilter,
@@ -192,7 +198,7 @@ class ScanGroupBox(QGroupBox):
vbox_layout = QVBoxLayout(self)
hbox_layout = QHBoxLayout()
vbox_layout.addLayout(hbox_layout)
self.layout = QGridLayout(self)
self.layout = QGridLayout()
vbox_layout.addLayout(self.layout)
# Add bundle button
@@ -285,8 +291,8 @@ class ScanGroupBox(QGroupBox):
)
else:
widget = widget_class(parent=self.parent(), arg_name=arg_name, default=default)
self._apply_numeric_precision(widget, item)
self._apply_numeric_limits(widget, item)
apply_numeric_precision(widget, item)
apply_numeric_limits(widget, item)
if isinstance(widget, DeviceComboBox):
self.selected_devices[widget] = ""
widget.device_selected.connect(self.emit_device_selected)
@@ -298,7 +304,7 @@ class ScanGroupBox(QGroupBox):
if isinstance(widget, ScanLiteralsComboBox):
widget.set_literals(item["type"].get("Literal", []))
self._widget_configs[widget] = item
self._apply_unit_metadata(widget, item)
apply_unit_metadata(widget, item)
self.layout.addWidget(widget, row, column_index)
self.widgets.append(widget)
@@ -307,7 +313,7 @@ class ScanGroupBox(QGroupBox):
sender = self.sender()
self.selected_devices[sender] = device_name.strip()
if isinstance(sender, DeviceComboBox):
units = self._device_units(sender.get_current_device())
units = device_units(sender.get_current_device())
self._update_reference_units(sender, units)
self._emit_reference_units_changed(sender, units)
selected_devices_str = " ".join(self.selected_devices.values())
@@ -453,57 +459,11 @@ class ScanGroupBox(QGroupBox):
WidgetIO.set_value(widget, value)
break
@staticmethod
def _unit_tooltip(item: dict, units: str | None = None) -> str | None:
tooltip = item.get("tooltip", None)
reference_units = item.get("reference_units", None)
units = units or item.get("units", None)
tooltip_parts = [tooltip] if tooltip else []
if units:
tooltip_parts.append(f"Units: {units}")
elif reference_units:
tooltip_parts.append(f"Units from: {reference_units}")
if tooltip_parts:
return "\n".join(tooltip_parts)
return None
def _apply_unit_metadata(self, widget, item: dict, units: str | None = None) -> None:
units = units or item.get("units", None)
tooltip = self._unit_tooltip(item, units)
existing_tooltip = widget.toolTip()
if existing_tooltip:
# strip the existing unit info from the tooltip if it exists
# to avoid tooltip bloat on multiple updates
existing_tooltip = "\n".join(
line
for line in existing_tooltip.splitlines()
if not (line.startswith("Units:") or line.startswith("Units from:"))
).strip()
if tooltip:
if existing_tooltip:
widget.setToolTip(f"{existing_tooltip}\n{tooltip}")
else:
widget.setToolTip(tooltip)
if hasattr(widget, "setSuffix"):
widget.setSuffix(f" {units}" if units else "")
def _refresh_column_label(self, column: int, item: dict) -> None:
if column not in self._column_labels:
return
self._column_labels[column].setText(item.get("display_name", item.get("name", None)))
@staticmethod
def _device_units(device) -> str | None:
egu = getattr(device, "egu", None)
if not callable(egu):
return None
try:
return egu()
except Exception:
logger.exception("Failed to fetch engineering units from device %s", device)
return None
def _widget_position(self, widget) -> tuple[int, int] | None:
for row in range(self.layout.rowCount()):
for column in range(self.layout.columnCount()):
@@ -529,7 +489,7 @@ class ScanGroupBox(QGroupBox):
row, column = widget_position
if self.box_type == "args" and row != source_row:
continue
self._apply_unit_metadata(widget, item, units)
apply_unit_metadata(widget, item, units)
self._refresh_column_label(column, item)
def apply_reference_units(self, reference_name: str, units: str | None) -> None:
@@ -543,7 +503,7 @@ class ScanGroupBox(QGroupBox):
item = self._widget_configs.get(widget, {})
if item.get("reference_units") != reference_name:
continue
self._apply_unit_metadata(widget, item, units)
apply_unit_metadata(widget, item, units)
position = self._widget_position(widget)
if position is not None:
_, column = position
@@ -562,49 +522,3 @@ class ScanGroupBox(QGroupBox):
self.selected_devices[device_widget] = ""
self._update_reference_units(device_widget, None)
self._emit_reference_units_changed(device_widget, None)
@staticmethod
def _apply_numeric_precision(widget: ScanDoubleSpinBox, item: dict) -> None:
if not isinstance(widget, ScanDoubleSpinBox):
return
precision = item.get("precision")
if precision is None:
return
try:
widget.setDecimals(max(0, int(precision)))
except (TypeError, ValueError):
logger.warning(
"Ignoring invalid precision %r for parameter %s", precision, item.get("name")
)
@staticmethod
def _apply_numeric_limits(widget: ScanDoubleSpinBox | ScanSpinBox, item: dict) -> None:
if isinstance(widget, ScanSpinBox):
minimum = -2147483647 # largest int which qt allows
maximum = 2147483647
if item.get("ge") is not None:
minimum = int(item["ge"])
if item.get("gt") is not None:
minimum = int(item["gt"]) + 1
if item.get("le") is not None:
maximum = int(item["le"])
if item.get("lt") is not None:
maximum = int(item["lt"]) - 1
widget.setRange(minimum, maximum)
return
if isinstance(widget, ScanDoubleSpinBox):
minimum = -float("inf")
maximum = float("inf")
step = 10 ** (-widget.decimals())
if item.get("ge") is not None:
minimum = float(item["ge"])
if item.get("gt") is not None:
minimum = float(item["gt"]) + step
if item.get("le") is not None:
maximum = float(item["le"])
if item.get("lt") is not None:
maximum = float(item["lt"]) - step
widget.setRange(minimum, maximum)
@@ -2,9 +2,12 @@
from __future__ import annotations
import re
from typing import Any
from bec_widgets.utils.scan_arg_metadata import format_display_name as format_scan_display_name
from bec_widgets.utils.scan_arg_metadata import resolve_tooltip as resolve_scan_tooltip
from bec_widgets.utils.scan_arg_metadata import ui_config_from_metadata
AnnotationValue = str | dict[str, Any] | list[Any] | None
ScanArgumentMetadata = dict[str, Any]
SignatureEntry = dict[str, Any]
@@ -74,8 +77,7 @@ class ScanInfoAdapter:
Returns:
str: Formatted display label such as ``Exp Time``.
"""
parts = re.split(r"(_|\d+)", name)
return " ".join(part.capitalize() for part in parts if part.isalnum()).strip()
return format_scan_display_name(name)
@staticmethod
def resolve_tooltip(scan_argument: ScanArgumentMetadata) -> str | None:
@@ -87,7 +89,7 @@ class ScanInfoAdapter:
Returns:
str | None: Explicit tooltip text if provided, otherwise the description fallback.
"""
return scan_argument.get("tooltip") or scan_argument.get("description")
return resolve_scan_tooltip(scan_argument)
@staticmethod
def parse_annotation(
@@ -204,24 +206,13 @@ class ScanInfoAdapter:
Returns:
ScanInputConfig: Normalized input configuration.
"""
return {
"arg": arg,
"name": name,
"type": self.scan_arg_type_from_annotation(annotation),
"display_name": scan_argument.get("display_name") or self.format_display_name(name),
"tooltip": self.resolve_tooltip(scan_argument),
"default": default,
"expert": scan_argument.get("expert", False),
"hidden": scan_argument.get("hidden", False),
"precision": scan_argument.get("precision"),
"units": scan_argument.get("units"),
"reference_units": scan_argument.get("reference_units"),
"gt": scan_argument.get("gt"),
"ge": scan_argument.get("ge"),
"lt": scan_argument.get("lt"),
"le": scan_argument.get("le"),
"alternative_group": scan_argument.get("alternative_group"),
}
return ui_config_from_metadata(
name=name,
metadata=scan_argument,
input_type=self.scan_arg_type_from_annotation(annotation),
default=default,
arg=arg,
)
def build_scan_ui_config(self, scan_info: ScanInfo) -> ScanUIConfig:
"""Normalize one available-scan entry into the widget UI configuration.
@@ -2,50 +2,41 @@ import sys
from enum import Enum
from string import Template
from qtpy.QtCore import QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer
from qtpy.QtGui import QColor, QPainter, QPainterPath
class ProgressState(Enum):
NORMAL = "normal"
PAUSED = "paused"
INTERRUPTED = "interrupted"
COMPLETED = "completed"
@classmethod
def from_bec_status(cls, status: str) -> "ProgressState":
"""
Map a BEC status string (open, paused, aborted, halted, closed)
to the corresponding ProgressState.
Any unknown status falls back to NORMAL.
"""
mapping = {
"open": cls.NORMAL,
"paused": cls.PAUSED,
"aborted": cls.INTERRUPTED,
"halted": cls.PAUSED,
"closed": cls.COMPLETED,
}
return mapping.get(status.lower(), cls.NORMAL)
PROGRESS_STATE_COLORS = {
ProgressState.NORMAL: QColor("#2979ff"), # blue normal progress
ProgressState.PAUSED: QColor("#ffca28"), # orange/amber paused
ProgressState.INTERRUPTED: QColor("#ff5252"), # red interrupted
ProgressState.COMPLETED: QColor("#00e676"), # green finished
}
from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
from qtpy.QtCore import QTimer
from qtpy.QtGui import QPalette
from qtpy.QtWidgets import QApplication, QProgressBar, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class ProgressState(Enum):
NORMAL = "normal"
PAUSED = "paused"
WARNING = "warning"
INTERRUPTED = "interrupted"
COMPLETED = "completed"
class BECProgressBar(BECWidget, QWidget):
"""
A custom progress bar with smooth transitions. The displayed text can be customized using a template.
A BEC progress bar backed by Qt's native QProgressBar.
The displayed text can be customized using a template with $value, $maximum,
and $percentage placeholders.
Args:
parent: Parent Qt widget.
client: Optional BEC client instance.
config: Optional widget configuration.
gui_id: Optional GUI identifier used by the BEC widget infrastructure.
enable_dynamic_stylesheet: If True, adjust the chunk border radius while the
filled chunk is still too narrow for the target radius. This avoids Qt
stylesheet over-rounding artifacts on small progress values. Once the
target radius is usable, normal value updates no longer rebuild the
stylesheet.
**kwargs: Additional keyword arguments forwarded to BECWidget.
"""
PLUGIN = True
@@ -61,7 +52,15 @@ class BECProgressBar(BECWidget, QWidget):
]
ICON_NAME = "page_control"
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
def __init__(
self,
parent=None,
client=None,
config=None,
gui_id=None,
enable_dynamic_stylesheet: bool = True,
**kwargs,
):
super().__init__(
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
)
@@ -71,7 +70,6 @@ class BECProgressBar(BECWidget, QWidget):
# internal values
self._oversampling_factor = 50
self._value = 0
self._target_value = 0
self._maximum = 100 * self._oversampling_factor
# User values
@@ -80,46 +78,38 @@ class BECProgressBar(BECWidget, QWidget):
self._user_maximum = 100
self._label_template = "$value / $maximum - $percentage %"
# Color settings
self._background_color = QColor(30, 30, 30)
self._progress_color = accent_colors.highlight
self._completed_color = accent_colors.success
self._border_color = QColor(50, 50, 50)
# Cornerrounding: base radius in pixels (autoreduced if bar is small)
self._corner_radius = 10
self._corner_radius = 8
# Progressbar state handling
self._state = ProgressState.NORMAL
self._state_colors = {
ProgressState.NORMAL: accent_colors.default,
ProgressState.PAUSED: accent_colors.warning,
ProgressState.PAUSED: accent_colors.highlight,
ProgressState.WARNING: accent_colors.warning,
ProgressState.INTERRUPTED: accent_colors.emergency,
ProgressState.COMPLETED: accent_colors.success,
}
# layout settings
self._padding_left_right = 10
self._value_animation = QPropertyAnimation(self, b"_progressbar_value")
self._value_animation.setDuration(200)
self._value_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
self._chunk_radius = None
self._enable_dynamic_stylesheet = enable_dynamic_stylesheet
# label on top of the progress bar
self.center_label = QLabel(self)
self.center_label.setAlignment(Qt.AlignHCenter)
self.center_label.setMinimumSize(0, 0)
self.center_label.setStyleSheet("background: transparent; color: white;")
self.progressbar = QProgressBar(self)
self.progressbar.setTextVisible(True)
self.progressbar.setRange(0, self._maximum)
self.progressbar.setMinimumHeight(0)
self.progressbar.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Ignored)
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 0, 10, 0)
layout.setSpacing(0)
layout.addWidget(self.center_label)
layout.setAlignment(self.center_label, Qt.AlignCenter)
self.setLayout(layout)
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(self._padding_left_right, 0, self._padding_left_right, 0)
self._layout.setSpacing(0)
self._layout.addWidget(self.progressbar)
self.setLayout(self._layout)
self.update()
self._adjust_label_width()
self._sync_progressbar()
self._apply_state_style()
@SafeProperty(
str, doc="The template for the center label. Use $value, $maximum, and $percentage."
@@ -140,17 +130,18 @@ class BECProgressBar(BECWidget, QWidget):
accent_colors = get_accent_colors()
self._state_colors = {
ProgressState.NORMAL: accent_colors.default,
ProgressState.PAUSED: accent_colors.warning,
ProgressState.PAUSED: accent_colors.highlight,
ProgressState.WARNING: accent_colors.warning,
ProgressState.INTERRUPTED: accent_colors.emergency,
ProgressState.COMPLETED: accent_colors.success,
}
self._chunk_radius = None
self._apply_state_style()
@label_template.setter
def label_template(self, template):
self._label_template = template
self._adjust_label_width()
self.set_value(self._user_value)
self.update()
self._sync_progressbar()
@SafeProperty(float, designable=False)
def _progressbar_value(self):
@@ -162,28 +153,16 @@ class BECProgressBar(BECWidget, QWidget):
@_progressbar_value.setter
def _progressbar_value(self, val):
self._value = val
self.update()
self.progressbar.setValue(int(round(val)))
def _update_template(self):
template = Template(self._label_template)
return template.safe_substitute(
value=self._user_value,
maximum=self._user_maximum,
percentage=int((self.map_value(self._user_value) / self._maximum) * 100),
percentage=int(self._percentage(self._user_value)),
)
def _adjust_label_width(self):
"""
Reserve enough horizontal space for the center label so the widget
doesn't resize as the text grows during progress.
"""
template = Template(self._label_template)
sample_text = template.safe_substitute(
value=self._user_maximum, maximum=self._user_maximum, percentage=100
)
width = self.center_label.fontMetrics().horizontalAdvance(sample_text)
self.center_label.setFixedWidth(width)
@SafeSlot(float)
@SafeSlot(int)
def set_value(self, value):
@@ -193,21 +172,35 @@ class BECProgressBar(BECWidget, QWidget):
Args:
value (float): The value to set.
"""
if value > self._user_maximum:
value = self._user_maximum
elif value < self._user_minimum:
value = self._user_minimum
self._target_value = self.map_value(value)
self._user_value = value
self.center_label.setText(self._update_template())
previous_visual_state = self._current_visual_state()
previous_value = self._value
self._user_value = self._clamp_value(value)
self._value = self.map_value(self._user_value)
if self._enable_dynamic_stylesheet and self._value < previous_value:
self._chunk_radius = None
# Update state automatically unless paused or interrupted
if self._state not in (ProgressState.PAUSED, ProgressState.INTERRUPTED):
if self._state not in (
ProgressState.PAUSED,
ProgressState.WARNING,
ProgressState.INTERRUPTED,
):
self._state = (
ProgressState.COMPLETED
if self._user_value >= self._user_maximum
else ProgressState.NORMAL
)
self.animate_progress()
self._sync_progressbar()
visual_state_changed = self._current_visual_state() is not previous_visual_state
if visual_state_changed:
self._chunk_radius = None
if (
self._enable_dynamic_stylesheet
and not visual_state_changed
and (self._chunk_radius is None or self._chunk_radius != self._target_chunk_radius())
):
self._update_chunk_radius()
if visual_state_changed:
self._apply_state_style()
@SafeProperty(object, doc="Current visual state of the progress bar.")
def state(self):
@@ -226,7 +219,8 @@ class BECProgressBar(BECWidget, QWidget):
if not isinstance(state, ProgressState):
raise ValueError("state must be a ProgressState or its value")
self._state = state
self.update()
self._chunk_radius = None
self._apply_state_style()
@SafeProperty(float, doc="Base corner radius in pixels (autoscaled down on small bars).")
def corner_radius(self) -> float:
@@ -235,7 +229,18 @@ class BECProgressBar(BECWidget, QWidget):
@corner_radius.setter
def corner_radius(self, radius: float):
self._corner_radius = max(0.0, radius)
self.update()
self._chunk_radius = None
self._apply_state_style()
@SafeProperty(bool)
def enable_dynamic_stylesheet(self) -> bool:
return self._enable_dynamic_stylesheet
@enable_dynamic_stylesheet.setter
def enable_dynamic_stylesheet(self, enabled: bool):
self._enable_dynamic_stylesheet = bool(enabled)
self._chunk_radius = None
self._apply_state_style()
@SafeProperty(float)
def padding_left_right(self) -> float:
@@ -244,60 +249,12 @@ class BECProgressBar(BECWidget, QWidget):
@padding_left_right.setter
def padding_left_right(self, padding: float):
self._padding_left_right = padding
self.update()
self._layout.setContentsMargins(int(round(padding)), 0, int(round(padding)), 0)
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect().adjusted(self._padding_left_right, 0, -self._padding_left_right, -1)
# Corner radius adapts to widget height so it never exceeds half the bars thickness
radius = min(self._corner_radius, rect.height() / 2)
# Draw background
painter.setBrush(self._background_color)
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(rect, radius, radius) # Rounded corners
# Draw border
painter.setBrush(Qt.NoBrush)
painter.setPen(self._border_color)
painter.drawRoundedRect(rect, radius, radius)
# Determine progress colour based on current state
if self._state == ProgressState.PAUSED:
current_color = self._state_colors[ProgressState.PAUSED]
elif self._state == ProgressState.INTERRUPTED:
current_color = self._state_colors[ProgressState.INTERRUPTED]
elif self._state == ProgressState.COMPLETED or self._value >= self._maximum:
current_color = self._state_colors[ProgressState.COMPLETED]
else:
current_color = self._state_colors[ProgressState.NORMAL]
# Set clipping region to preserve the background's rounded corners
progress_rect = rect.adjusted(
0, 0, int(-rect.width() + (self._value / self._maximum) * rect.width()), 0
)
clip_path = QPainterPath()
clip_path.addRoundedRect(
QRectF(rect), radius, radius
) # Clip to the background's rounded corners
painter.setClipPath(clip_path)
# Draw progress bar
painter.setBrush(current_color)
painter.drawRect(progress_rect) # Less rounded, no additional rounding
painter.end()
def animate_progress(self):
"""
Animate the progress bar from the current value to the target value.
"""
self._value_animation.stop()
self._value_animation.setStartValue(self._value)
self._value_animation.setEndValue(self._target_value)
self._value_animation.start()
def resizeEvent(self, event):
super().resizeEvent(event)
self._chunk_radius = None
self._update_chunk_radius()
@SafeProperty(float)
def maximum(self):
@@ -343,10 +300,11 @@ class BECProgressBar(BECWidget, QWidget):
Args:
maximum (float): The maximum value.
"""
previous_maximum = self._user_maximum
self._user_maximum = maximum
self._adjust_label_width()
if self._enable_dynamic_stylesheet and maximum != previous_maximum:
self._chunk_radius = None
self.set_value(self._user_value) # Update the value to fit the new range
self.update()
@SafeSlot(float)
def set_minimum(self, minimum: float):
@@ -356,40 +314,126 @@ class BECProgressBar(BECWidget, QWidget):
Args:
minimum (float): The minimum value.
"""
previous_minimum = self._user_minimum
self._user_minimum = minimum
if self._enable_dynamic_stylesheet and minimum != previous_minimum:
self._chunk_radius = None
self.set_value(self._user_value) # Update the value to fit the new range
self.update()
def map_value(self, value: float):
"""
Map the user value to the range [0, 100*self._oversampling_factor] for the progress
"""
return (
(value - self._user_minimum) / (self._user_maximum - self._user_minimum) * self._maximum
)
span = self._user_maximum - self._user_minimum
if span <= 0:
return float(self._maximum if value >= self._user_maximum else 0)
mapped_value = (value - self._user_minimum) / span * self._maximum
return min(float(self._maximum), max(0.0, mapped_value))
def _percentage(self, value: float) -> float:
return (self.map_value(value) / self._maximum) * 100 if self._maximum else 0.0
def _clamp_value(self, value: float) -> float:
if self._user_maximum <= self._user_minimum:
return self._user_maximum
return min(self._user_maximum, max(self._user_minimum, value))
def _sync_progressbar(self) -> None:
self.progressbar.setRange(0, int(self._maximum))
self.progressbar.setValue(int(round(self._value)))
self.progressbar.setFormat(self._update_template())
def _setup_style_sheet(self, *, chunk_radius: int) -> None:
radius = int(round(self._corner_radius))
chunk_color = self._state_colors[self._current_visual_state()].name()
self.progressbar.setStyleSheet(f"""
QProgressBar {{
background-color: palette(mid);
border: none;
border-radius: {radius}px;
color: palette(text);
text-align: center;
}}
QProgressBar::chunk {{
background-color: {chunk_color};
border-radius: {chunk_radius}px;
}}
""")
def _update_chunk_radius(self) -> None:
chunk_radius = self._current_chunk_radius()
if chunk_radius != self._chunk_radius:
self._chunk_radius = chunk_radius
self._setup_style_sheet(chunk_radius=chunk_radius)
self._apply_state_palette()
def _apply_state_style(self) -> None:
if self._chunk_radius is None:
self._chunk_radius = self._current_chunk_radius()
self._setup_style_sheet(chunk_radius=self._chunk_radius)
self._apply_state_palette()
def _apply_state_palette(self) -> None:
color = self._state_colors[self._current_visual_state()]
palette = self.progressbar.palette()
palette.setColor(QPalette.ColorRole.Highlight, color)
palette.setColor(QPalette.ColorRole.HighlightedText, palette.color(QPalette.ColorRole.Text))
self.progressbar.setPalette(palette)
def _current_chunk_radius(self) -> int:
target_radius = self._target_chunk_radius()
if not self._enable_dynamic_stylesheet:
return target_radius
return self._calculate_chunk_radius(target_radius)
def _target_chunk_radius(self) -> int:
radius = int(round(self._corner_radius))
return max(0, radius - 1)
def _calculate_chunk_radius(self, target_radius: int) -> int:
"""
Scale the chunk radius down while the filled part is narrower than the target radius.
Qt stylesheets otherwise over-round very small chunks.
"""
if target_radius <= 0 or self._maximum <= 0:
return 0
fill_width = self.progressbar.width() * min(1.0, max(0.0, self._value / self._maximum))
if fill_width <= 0:
return 0
return min(target_radius, max(1, int(fill_width / 2)))
def _current_visual_state(self) -> ProgressState:
if self._state in (ProgressState.PAUSED, ProgressState.WARNING, ProgressState.INTERRUPTED):
return self._state
if self._state == ProgressState.COMPLETED or self._value >= self._maximum:
return ProgressState.COMPLETED
return ProgressState.NORMAL
def _get_label(self) -> str:
"""Return the label text. mostly used for testing rpc."""
return self.center_label.text()
return self.progressbar.text()
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
progressBar = BECProgressBar()
progressBar.show()
progressBar.set_minimum(-100)
progressBar.set_maximum(0)
progress_bar = BECProgressBar()
progress_bar.setWindowTitle("BEC Progress Bar")
progress_bar.resize(360, 48)
progress_bar.set_minimum(-100)
progress_bar.set_maximum(0)
progress_bar.set_value(-100)
progress_bar.show()
# Example of setting values
def update_progress():
value = progressBar._user_value + 2.5
if value > progressBar._user_maximum:
value = -100 # progressBar._maximum / progressBar._upsampling_factor
progressBar.set_value(value)
value = progress_bar._user_value + 2.5
if value > progress_bar._user_maximum:
value = progress_bar._user_minimum
progress_bar.set_value(value)
timer = QTimer()
timer = QTimer(progress_bar)
timer.timeout.connect(update_progress)
timer.start(200) # Update every half second
timer.start(200)
sys.exit(app.exec())
@@ -0,0 +1,288 @@
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Literal
import numpy as np
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QObject, QTimer, Signal
from bec_widgets.utils.error_popups import SafeSlot
@dataclass(frozen=True)
class ProgressSnapshot:
value: float
max_value: float
done: bool
status: Literal["open", "paused", "aborted", "halted", "closed", "user_completed"]
scan_id: str | None = None
scan_number: int | None = None
rid: str | None = None
is_new_scan: bool = False
class ProgressTask(QObject):
"""
Class to store progress information.
Inspired by https://github.com/Textualize/rich/blob/master/rich/progress.py
"""
def __init__(
self, parent: QObject | None, value: float = 0, max_value: float = 0, done: bool = False
):
super().__init__(parent=parent)
self.start_time = time.monotonic()
self.done = done
self.value = value
self.max_value = max_value
self._elapsed_time = 0
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_elapsed_time)
self.timer.start(1000)
def update(self, value: float, max_value: float, done: bool = False):
"""
Update the progress.
"""
self.max_value = max_value
self.done = done
self.value = value
if done:
self.timer.stop()
def update_elapsed_time(self):
"""
Update the time estimates. This is called every second by a QTimer.
"""
self._elapsed_time = max(0.0, time.monotonic() - self.start_time)
@property
def percentage(self) -> float:
"""float: Get progress of task as a percentage. If a None total was set, returns 0"""
if not self.max_value:
return 0.0
completed = (self.value / self.max_value) * 100.0
completed = min(100.0, max(0.0, completed))
return completed
@property
def speed(self) -> float:
"""Get the estimated speed in steps per second."""
if self._elapsed_time == 0:
return 0.0
return self.value / self._elapsed_time
@property
def frequency(self) -> float:
"""Get the estimated frequency in steps per second."""
if self.speed == 0:
return 0.0
return 1 / self.speed
@property
def time_elapsed(self) -> str:
return self._format_time(int(self._elapsed_time))
@property
def remaining(self) -> float:
"""Get the estimated remaining steps."""
if self.done:
return 0.0
remaining = self.max_value - self.value
return remaining
@property
def time_remaining(self) -> str:
"""
Get the estimated remaining time in the format HH:MM:SS.
"""
if self.done or not self.speed or not self.remaining:
return self._format_time(0)
estimate = int(np.round(self.remaining / self.speed))
return self._format_time(estimate)
@staticmethod
def _format_time(seconds: float) -> str:
"""
Format the time in seconds to a string in the format HH:MM:SS.
"""
return f"{seconds // 3600:02}:{(seconds // 60) % 60:02}:{seconds % 60:02}"
class BECProgressTracker(QObject):
"""
Shared backend for BEC scan progress messages.
"""
progress_started = Signal(object)
progress_updated = Signal(object)
progress_finished = Signal(object)
progress_cleared = Signal()
def __init__(self, bec_dispatcher, parent: QObject | None = None):
super().__init__(parent=parent)
self.bec_dispatcher = bec_dispatcher
self._connected = False
self.task: ProgressTask | None = None
self.scan_number: int | None = None
self._active_scan_id: str | None = None
self._active_rid: str | None = None
self._last_reset_scan_id: str | None = None
self._last_progress_scan_id: str | None = None
def start(self) -> None:
if self._connected:
return
self.bec_dispatcher.connect_slot(
self.process_progress_message, MessageEndpoints.scan_progress()
)
self.bec_dispatcher.connect_slot(
self.process_scan_status_message, MessageEndpoints.scan_status()
)
self._connected = True
def _start_task(self, scan_id: str | None, rid: str | None = None) -> None:
if self.task is not None:
self.task.timer.stop()
self.task.deleteLater()
self.task = ProgressTask(parent=self)
self._active_scan_id = scan_id
self._active_rid = rid
self.progress_started.emit(
ProgressSnapshot(
value=0,
max_value=100,
done=False,
status="open",
scan_id=self._active_scan_id,
scan_number=self.scan_number,
rid=self._active_rid,
)
)
def clear_task(self, *, emit_finished: bool = True) -> None:
if self.task is None:
self._active_scan_id = None
self._active_rid = None
self.progress_cleared.emit()
return
self.task.timer.stop()
self.task.deleteLater()
self.task = None
self._active_scan_id = None
self._active_rid = None
self.progress_cleared.emit()
if emit_finished:
self.progress_finished.emit(
ProgressSnapshot(
value=0,
max_value=100,
done=True,
status="open",
scan_id=self._active_scan_id,
scan_number=self.scan_number,
rid=self._active_rid,
)
)
@SafeSlot(dict, dict)
def process_progress_message(
self, msg_content: dict, metadata: dict
) -> ProgressSnapshot | None:
done = msg_content.get("done", False)
value = msg_content.get("value", 0)
max_value = msg_content.get("max_value", 100)
status: Literal["open", "paused", "aborted", "halted", "closed", "user_completed"] = (
metadata.get("status", "open")
)
scan_id = metadata.get("scan_id") or metadata.get("RID")
rid = metadata.get("RID")
scan_number = metadata.get("scan_number")
if scan_number is not None:
self.scan_number = scan_number
if scan_id is not None:
self._last_progress_scan_id = scan_id
is_new_scan = False
previous_scan_id = self._active_scan_id
previous_rid = self._active_rid
identity_changed = (
(scan_id is not None and scan_id != previous_scan_id)
or (rid is not None and rid != previous_rid)
or (previous_scan_id is None and previous_rid is None)
)
if self.task is None:
self._start_task(scan_id, rid=rid)
is_new_scan = identity_changed
elif scan_id is not None and scan_id != self._active_scan_id:
self._start_task(scan_id, rid=rid)
is_new_scan = True
elif rid is not None and rid != self._active_rid:
self._start_task(scan_id or self._active_scan_id, rid=rid)
is_new_scan = True
if self.task is None:
return None
self.task.update(value, max_value, done)
snapshot = ProgressSnapshot(
value=value,
max_value=max_value,
done=done,
status=status,
scan_id=self._active_scan_id,
scan_number=self.scan_number,
rid=self._active_rid,
is_new_scan=is_new_scan,
)
self.progress_updated.emit(snapshot)
if done:
self.clear_task()
return snapshot
@SafeSlot(dict, dict)
def process_scan_status_message(
self, msg_content: dict, metadata: dict
) -> ProgressSnapshot | None:
if msg_content.get("status") != "open":
return None
scan_id = msg_content.get("scan_id") or metadata.get("scan_id") or metadata.get("RID")
if scan_id is None or scan_id == self._last_reset_scan_id:
return None
if scan_id == self._last_progress_scan_id:
self._last_reset_scan_id = scan_id
return None
self.clear_task(emit_finished=False)
self._last_reset_scan_id = scan_id
self.scan_number = msg_content.get("scan_number")
snapshot = ProgressSnapshot(
value=0,
max_value=100,
done=False,
status="open",
scan_id=scan_id,
scan_number=self.scan_number,
rid=metadata.get("RID"),
is_new_scan=True,
)
self.progress_updated.emit(snapshot)
return snapshot
def cleanup(self) -> None:
self.clear_task(emit_finished=False)
if self._connected:
self.bec_dispatcher.disconnect_slot(
self.process_progress_message, MessageEndpoints.scan_progress()
)
self.bec_dispatcher.disconnect_slot(
self.process_scan_status_message, MessageEndpoints.scan_status()
)
self._connected = False
self._last_reset_scan_id = None
self._last_progress_scan_id = None
@@ -13,6 +13,7 @@ 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
from bec_widgets.widgets.progress.progress_backend import BECProgressTracker, ProgressSnapshot
logger = bec_logger.logger
if TYPE_CHECKING:
@@ -81,6 +82,8 @@ class Ring(BECWidget, QWidget):
self._color: QColor = self.convert_color(self.config.color)
self._background_color: QColor = self.convert_color(self.config.background_color)
self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None
self.progress_tracker = BECProgressTracker(self.bec_dispatcher, parent=self)
self.progress_tracker.progress_updated.connect(self._on_progress_snapshot)
self.RID = None
self._gap = 5
self._hovered = False
@@ -219,35 +222,32 @@ class Ring(BECWidget, QWidget):
case "manual":
if self.config.mode == "manual":
return
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self._disconnect_registered_update()
self.config.mode = "manual"
self.registered_slot = None
case "scan":
if self.config.mode == "scan":
return
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self._disconnect_registered_update()
self.config.mode = "scan"
self.bec_dispatcher.connect_slot(
self.on_scan_progress, MessageEndpoints.scan_progress()
)
self.registered_slot = (self.on_scan_progress, MessageEndpoints.scan_progress())
self.progress_tracker.start()
case "device":
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self._disconnect_registered_update()
self.config.mode = "device"
if device == "":
self.registered_slot = None
return
self.config.device = device
# self.config.signal = self._get_signal_from_device(device, signal)
signal = self._update_device_connection(device, signal)
self.config.signal = signal
case _:
raise ValueError(f"Unsupported mode: {mode}")
def _disconnect_registered_update(self):
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self.registered_slot = None
self.progress_tracker.cleanup()
def set_precision(self, precision: int):
"""
Set the precision for the ring widget.
@@ -270,13 +270,13 @@ class Ring(BECWidget, QWidget):
def _get_signals_for_device(self, device: str) -> dict[str, list[str]]:
"""
Get the signals for the device.
Get the appropriate signals for the device to be used in the ring widget, based on the signal infos from the device manager.
Args:
device(str): Device name for the device
device(str): Device name for the device readback mode
Returns:
dict[str, list[str]]: Dictionary with the signals for the device
dict[str, list[str]]: Signal infos for the device to be used in the ring widget
"""
dm = self.bec_dispatcher.client.device_manager
if not dm:
@@ -285,24 +285,25 @@ class Ring(BECWidget, QWidget):
if dev_obj is None:
raise ValueError(f"Device '{device}' not found in device manager.")
signal_infos = getattr(dev_obj, "_info", {}).get("signals", {})
progress_signals = [
obj["component_name"]
for obj in dev_obj._info["signals"].values()
if obj["signal_class"] == "ProgressSignal"
for obj in signal_infos.values()
if obj.get("signal_class") == "ProgressSignal"
]
hinted_signals = [
obj["obj_name"]
for obj in dev_obj._info["signals"].values()
if obj["kind_str"] == "hinted"
and obj["signal_class"]
for obj in signal_infos.values()
if obj.get("kind_str") == "hinted"
and obj.get("signal_class")
not in ["ProgressSignal", "AsyncSignal", "AsyncMultiSignal", "DynamicSignal"]
]
normal_signals = [
obj["component_name"]
for obj in dev_obj._info["signals"].values()
if obj["kind_str"] == "normal"
for obj in signal_infos.values()
if obj.get("kind_str") == "normal"
]
return {
"progress_signals": progress_signals,
"hinted_signals": hinted_signals,
@@ -311,21 +312,15 @@ class Ring(BECWidget, QWidget):
def _update_device_connection(self, device: str, signal: str | None) -> str:
"""
Update the device connection for the ring widget.
Subscribe device mode to the endpoint matching the selected signal.
In general, we support two modes here:
- If signal is provided, we use that directly.
- If signal is not provided, we try to get the signal from the device manager.
We first check for progress signals, then for hinted signals, and finally for normal signals.
Depending on what type of signal we get (progress or hinted/normal), we subscribe to different endpoints.
Args:
device(str): Device name for the device mode
signal(str): Signal name for the device mode
When no signal is provided, the ring selects the first available progress
signal, then the first hinted readback signal, then the first normal
readback signal. Progress signals use the device_progress endpoint;
readback signals use the device_readback endpoint.
Returns:
str: The selected signal name for the device mode
The selected signal name, or an empty string if the device is not known.
"""
logger.info(f"Updating device connection for device '{device}' and signal '{signal}'")
dm = self.bec_dispatcher.client.device_manager
@@ -341,18 +336,17 @@ class Ring(BECWidget, QWidget):
normal_signals = signals["normal_signals"]
if not signal:
# If signal is not provided, we try to get it from the device manager
if len(progress_signals) > 0:
if progress_signals:
signal = progress_signals[0]
logger.info(
f"Using progress signal '{signal}' for device '{device}' in ring progress bar."
)
elif len(hinted_signals) > 0:
elif hinted_signals:
signal = hinted_signals[0]
logger.info(
f"Using hinted signal '{signal}' for device '{device}' in ring progress bar."
)
elif len(normal_signals) > 0:
elif normal_signals:
signal = normal_signals[0]
logger.info(
f"Using normal signal '{signal}' for device '{device}' in ring progress bar."
@@ -366,26 +360,18 @@ class Ring(BECWidget, QWidget):
self.bec_dispatcher.connect_slot(self.on_device_progress, endpoint)
self.registered_slot = (self.on_device_progress, endpoint)
return signal
if signal in hinted_signals or signal in normal_signals:
endpoint = MessageEndpoints.device_readback(device)
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoint)
self.registered_slot = (self.on_device_readback, endpoint)
return signal
@SafeSlot(dict, dict)
def on_scan_progress(self, msg, meta):
"""
Update the ring widget with the scan progress.
Args:
msg(dict): Message with the scan progress
meta(dict): Metadata for the message
"""
current_RID = meta.get("RID", None)
if current_RID != self.RID:
self.set_min_max_values(0, msg.get("max_value", 100))
self.set_value(msg.get("value", 0))
self.update()
raise ValueError(
f"Signal '{signal}' is not usable for ring progress device mode. "
f"Available progress signals: {progress_signals}; "
f"available readback signals: {hinted_signals + normal_signals}."
)
@SafeSlot(dict, dict)
def on_device_readback(self, msg, meta):
@@ -408,30 +394,31 @@ class Ring(BECWidget, QWidget):
@SafeSlot(dict, dict)
def on_device_progress(self, msg, meta):
"""
Update the ring widget with the device progress.
Args:
msg(dict): Message with the device progress
meta(dict): Metadata for the message
"""
device = self.config.device
if device is None:
return
max_val = msg.get("max_value", 100)
self.set_min_max_values(0, max_val)
value = msg.get("value", 0)
if msg.get("done"):
value = max_val
self.set_value(value)
self.set_value(max_val if msg.get("done") else msg.get("value", 0))
self.update()
def _on_progress_snapshot(self, snapshot: ProgressSnapshot):
if snapshot.is_new_scan:
self.set_min_max_values(0, snapshot.max_value)
self.RID = snapshot.rid
self.set_value(snapshot.value)
self.update()
def paintEvent(self, event):
if not self.progress_container:
return
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
size = min(self.width(), self.height())
if size <= 0 or not self.isVisible():
return
painter = QtGui.QPainter(self)
if not painter.isActive():
return
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
# Center the ring
x_offset = (self.width() - size) // 2
@@ -509,15 +496,6 @@ class Ring(BECWidget, QWidget):
return QtGui.QColor(*color)
raise ValueError(f"Unsupported color format: {color}")
def cleanup(self):
"""
Cleanup the ring widget.
Disconnect any registered slots.
"""
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self.registered_slot = None
###############################################
####### QProperties ###########################
###############################################
@@ -666,6 +644,7 @@ class Ring(BECWidget, QWidget):
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self.registered_slot = None
self.progress_tracker.cleanup()
self._hover_animation.stop()
super().cleanup()
@@ -1,121 +1,27 @@
from __future__ import annotations
import enum
import os
import time
from typing import Literal
import numpy as np
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject, QTimer, Signal
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import ProgressState
from bec_widgets.widgets.progress.progress_backend import BECProgressTracker, ProgressSnapshot
logger = bec_logger.logger
class ProgressSource(enum.Enum):
"""
Enum to define the source of the progress.
"""
SCAN_PROGRESS = "scan_progress"
DEVICE_PROGRESS = "device_progress"
class ProgressTask(QObject):
"""
Class to store progress information.
Inspired by https://github.com/Textualize/rich/blob/master/rich/progress.py
"""
def __init__(self, parent: QWidget, value: float = 0, max_value: float = 0, done: bool = False):
super().__init__(parent=parent)
self.start_time = time.time()
self.done = done
self.value = value
self.max_value = max_value
self._elapsed_time = 0
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_elapsed_time)
self.timer.start(100) # update the elapsed time every 100 ms
def update(self, value: float, max_value: float, done: bool = False):
"""
Update the progress.
"""
self.max_value = max_value
self.done = done
self.value = value
if done:
self.timer.stop()
def update_elapsed_time(self):
"""
Update the time estimates. This is called every 100 ms by a QTimer.
"""
self._elapsed_time += 0.1
@property
def percentage(self) -> float:
"""float: Get progress of task as a percentage. If a None total was set, returns 0"""
if not self.max_value:
return 0.0
completed = (self.value / self.max_value) * 100.0
completed = min(100.0, max(0.0, completed))
return completed
@property
def speed(self) -> float:
"""Get the estimated speed in steps per second."""
if self._elapsed_time == 0:
return 0.0
return self.value / self._elapsed_time
@property
def frequency(self) -> float:
"""Get the estimated frequency in steps per second."""
if self.speed == 0:
return 0.0
return 1 / self.speed
@property
def time_elapsed(self) -> str:
# format the elapsed time to a string in the format HH:MM:SS
return self._format_time(int(self._elapsed_time))
@property
def remaining(self) -> float:
"""Get the estimated remaining steps."""
if self.done:
return 0.0
remaining = self.max_value - self.value
return remaining
@property
def time_remaining(self) -> str:
"""
Get the estimated remaining time in the format HH:MM:SS.
"""
if self.done or not self.speed or not self.remaining:
return self._format_time(0)
estimate = int(np.round(self.remaining / self.speed))
return self._format_time(estimate)
def _format_time(self, seconds: float) -> str:
"""
Format the time in seconds to a string in the format HH:MM:SS.
"""
return f"{seconds // 3600:02}:{(seconds // 60) % 60:02}:{seconds % 60:02}"
BEC_STATUS_TO_PROGRESS_STATE = {
"open": ProgressState.NORMAL,
"paused": ProgressState.PAUSED,
"aborted": ProgressState.WARNING,
"halted": ProgressState.INTERRUPTED,
"closed": ProgressState.COMPLETED,
"user_completed": ProgressState.COMPLETED,
}
class ScanProgressBar(BECWidget, QWidget):
@@ -130,7 +36,14 @@ class ScanProgressBar(BECWidget, QWidget):
progress_finished = Signal()
def __init__(
self, parent=None, client=None, config=None, gui_id=None, one_line_design=False, **kwargs
self,
parent=None,
client=None,
config=None,
gui_id=None,
one_line_design=False,
enable_dynamic_stylesheet: bool = True,
**kwargs,
):
super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs)
@@ -146,83 +59,43 @@ class ScanProgressBar(BECWidget, QWidget):
self.layout.addWidget(self.ui)
self.setLayout(self.layout)
self.progressbar = self.ui.progressbar
self.progressbar.enable_dynamic_stylesheet = enable_dynamic_stylesheet
self._show_elapsed_time = self.ui.elapsed_time_label.isVisible()
self._show_remaining_time = self.ui.remaining_time_label.isVisible()
self._show_source_label = self.ui.source_label.isVisible()
self.connect_to_queue()
self._progress_source = None
self._progress_device = None
self.task = None
self.scan_number = None
def connect_to_queue(self):
"""
Connect to the queue status signal.
"""
self.bec_dispatcher.connect_slot(self.on_queue_update, MessageEndpoints.scan_queue_status())
def set_progress_source(self, source: ProgressSource, device=None):
"""
Set the source of the progress.
"""
if self._progress_source == source and self._progress_device == device:
self.update_source_label(source, device=device)
return
if self._progress_source is not None:
self.bec_dispatcher.disconnect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if self._progress_source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=self._progress_device)
),
)
self._progress_source = source
self._progress_device = None if source == ProgressSource.SCAN_PROGRESS else device
self.bec_dispatcher.connect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=device)
),
self.progress_tracker = BECProgressTracker(self.bec_dispatcher, parent=self)
self.progress_tracker.progress_started.connect(self._on_progress_started)
self.progress_tracker.progress_updated.connect(self._on_progress_snapshot)
self.progress_tracker.progress_finished.connect(
lambda _snapshot: self.progress_finished.emit()
)
self.update_source_label(source, device=device)
# self.progress_started.emit()
self.progress_tracker.start()
def update_source_label(self, source: ProgressSource, device=None):
scan_text = f"Scan {self.scan_number}" if self.scan_number is not None else "Scan"
text = scan_text if source == ProgressSource.SCAN_PROGRESS else f"Device {device}"
logger.info(f"Set progress source to {text}")
self.ui.source_label.setText(text)
@SafeSlot(dict, dict)
def on_progress_update(self, msg_content: dict, metadata: dict):
"""
Update the progress bar based on the progress message.
"""
value = msg_content["value"]
max_value = msg_content.get("max_value", 100)
done = msg_content.get("done", False)
status: Literal["open", "paused", "aborted", "halted", "closed"] = metadata.get(
"status", "open"
)
if self.task is None:
def update_source_label(self):
scan_number = self.progress_tracker.scan_number
scan_text = f"Scan {scan_number}" if scan_number is not None else "Scan"
if self.ui.source_label.text() == scan_text:
return
self.task.update(value, max_value, done)
logger.info(f"Set progress source to {scan_text}")
self.ui.source_label.setText(scan_text)
def _on_progress_started(self, _snapshot: ProgressSnapshot):
if self.progress_tracker.task is not None:
self.progress_tracker.task.timer.timeout.connect(self.update_labels)
self.progress_started.emit()
def _on_progress_snapshot(self, snapshot: ProgressSnapshot):
self.update_labels()
self.progressbar.set_maximum(self.task.max_value)
self.progressbar.state = ProgressState.from_bec_status(status)
self.progressbar.set_value(self.task.value)
if done:
self.task = None
self.progress_finished.emit()
return
if snapshot.is_new_scan and self.progress_tracker.task is None:
self.ui.elapsed_time_label.setText("00:00:00")
self.ui.remaining_time_label.setText("00:00:00")
self.update_source_label()
self.progressbar.set_maximum(snapshot.max_value)
self.progressbar.set_value(snapshot.value)
self.progressbar.state = BEC_STATUS_TO_PROGRESS_STATE.get(
snapshot.status.lower(), ProgressState.NORMAL
)
@SafeProperty(bool)
def show_elapsed_time(self):
@@ -259,74 +132,17 @@ class ScanProgressBar(BECWidget, QWidget):
"""
Update the labels based on the progress task.
"""
if self.task is None:
task = self.progress_tracker.task
if task is None:
return
self.ui.elapsed_time_label.setText(self.task.time_elapsed)
self.ui.remaining_time_label.setText(self.task.time_remaining)
@SafeSlot(dict, dict, verify_sender=True)
def on_queue_update(self, msg_content, metadata):
"""
Update the progress bar based on the queue status.
"""
if not "queue" in msg_content:
return
if "primary" not in msg_content["queue"]:
return
if (primary_queue := msg_content.get("queue").get("primary")) is None:
return
if not isinstance(primary_queue, messages.ScanQueueStatus):
return
primary_queue_info = primary_queue.info
if len(primary_queue_info) == 0:
return
scan_info = primary_queue_info[0]
if scan_info is None:
return
if scan_info.status.lower() == "running" and self.task is None:
self.task = ProgressTask(parent=self)
self.progress_started.emit()
active_request_block = scan_info.active_request_block
if active_request_block is None:
return
self.scan_number = active_request_block.scan_number
report_instructions = active_request_block.report_instructions
if not report_instructions:
return
# for now, let's just use the first instruction
instruction = report_instructions[0]
if "scan_progress" in instruction:
self.set_progress_source(ProgressSource.SCAN_PROGRESS)
elif "device_progress" in instruction:
device = instruction["device_progress"][0]
self.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
self.ui.elapsed_time_label.setText(task.time_elapsed)
self.ui.remaining_time_label.setText(task.time_remaining)
def cleanup(self):
if self.task is not None:
self.task.timer.stop()
self.close()
self.deleteLater()
if self._progress_source is not None:
self.bec_dispatcher.disconnect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if self._progress_source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=self._progress_device)
),
)
self._progress_source = None
self._progress_device = None
self.progress_tracker.cleanup()
self.progressbar.close()
self.progressbar.deleteLater()
self.bec_dispatcher.disconnect_slot(
self.on_queue_update, MessageEndpoints.scan_queue_status()
)
super().cleanup()
@@ -0,0 +1,607 @@
from __future__ import annotations
import sys
from typing import Any
from bec_lib import bl_states, messages
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
from qtpy.QtCore import QAbstractListModel, QModelIndex, QSize, Qt
from qtpy.QtWidgets import (
QAbstractItemView,
QApplication,
QDialog,
QHBoxLayout,
QLabel,
QListView,
QMessageBox,
QSizePolicy,
QStyledItemDelegate,
QStyleOptionViewItem,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.services.beamline_states.beamline_state_pill import BeamlineStatePill
from bec_widgets.widgets.services.beamline_states.dialogs import (
AddBeamlineStateDialog,
DeviceFilterDialog,
StatusFilterDialog,
)
class _BeamlineStateListModel(QAbstractListModel):
"""Model owning beamline state row identity and configuration data."""
NameRole = Qt.ItemDataRole.UserRole + 1
ConfigRole = Qt.ItemDataRole.UserRole + 2
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._state_order: list[str] = []
self._state_rows: dict[str, int] = {}
self._state_configs: dict[str, messages.BeamlineStateConfig] = {}
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802
return 0 if parent.isValid() else len(self._state_order)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
if not index.isValid() or not 0 <= index.row() < len(self._state_order):
return None
name = self._state_order[index.row()]
if role in (Qt.ItemDataRole.DisplayRole, self.NameRole):
return name
if role == self.ConfigRole:
return self._state_configs.get(name)
return None
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
if not index.isValid():
return Qt.ItemFlag.NoItemFlags
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
def set_states(self, state_configs: list[messages.BeamlineStateConfig]) -> None:
new_order = [state.name for state in state_configs]
new_configs = {state.name: state for state in state_configs}
for row in reversed(
[row for row, name in enumerate(self._state_order) if name not in new_configs]
):
self.beginRemoveRows(QModelIndex(), row, row)
name = self._state_order.pop(row)
self._state_configs.pop(name, None)
self.endRemoveRows()
self._rebuild_rows()
for target_row, name in enumerate(new_order):
if name not in self._state_rows:
self.beginInsertRows(QModelIndex(), target_row, target_row)
self._state_order.insert(target_row, name)
self._state_configs[name] = new_configs[name]
self.endInsertRows()
self._rebuild_rows()
continue
current_row = self._state_rows[name]
if current_row != target_row:
destination_row = target_row if current_row > target_row else target_row + 1
self.beginMoveRows(
QModelIndex(), current_row, current_row, QModelIndex(), destination_row
)
self._state_order.insert(target_row, self._state_order.pop(current_row))
self.endMoveRows()
self._rebuild_rows()
if self._state_configs.get(name) != new_configs[name]:
self._state_configs[name] = new_configs[name]
index = self.index(self._state_rows[name], 0)
self.dataChanged.emit(index, index, [self.ConfigRole])
def _rebuild_rows(self) -> None:
self._state_rows = {name: row for row, name in enumerate(self._state_order)}
def index_for_name(self, name: str) -> QModelIndex:
row = self._state_rows.get(name)
if row is None:
return QModelIndex()
return self.index(row, 0)
class _BeamlineStatePillDelegate(QStyledItemDelegate):
"""Delegate that provides BeamlineStatePill persistent editors for list rows."""
def __init__(self, manager: "BeamlineStateManager") -> None:
super().__init__(manager)
self._manager = manager
def paint(self, _painter, _option: QStyleOptionViewItem, _index: QModelIndex) -> None:
return
def createEditor( # noqa: N802
self, parent: QWidget, _option: QStyleOptionViewItem, index: QModelIndex
) -> QWidget:
name = index.data(_BeamlineStateListModel.NameRole)
state_config = index.data(_BeamlineStateListModel.ConfigRole)
pill = BeamlineStatePill(parent=parent, state_name=name, client=self._manager.client)
pill.idle_card_background = self._manager.idle_card_background
pill.set_state_config(state_config)
pill.state_changed.connect(self._manager._on_pill_state_changed)
pill.update_requested.connect(self._manager._update_state_parameters)
pill.remove_requested.connect(self._manager._remove_state_requested)
pill.row_height_changed.connect(lambda name=name: self._manager._sync_pill_item_size(name))
self._manager._state_pills[str(name)] = pill
return pill
def setEditorData(self, editor: QWidget, index: QModelIndex) -> None: # noqa: N802
if not isinstance(editor, BeamlineStatePill):
return
name = index.data(_BeamlineStateListModel.NameRole)
state_config = index.data(_BeamlineStateListModel.ConfigRole)
editor.set_state_name(str(name))
editor.idle_card_background = self._manager.idle_card_background
editor.set_state_config(state_config)
def updateEditorGeometry( # noqa: N802
self, editor: QWidget, option: QStyleOptionViewItem, _index: QModelIndex
) -> None:
editor.setGeometry(option.rect)
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: # noqa: N802
name = index.data(_BeamlineStateListModel.NameRole)
pill = self._manager._state_pills.get(str(name))
if pill is not None:
return pill.sizeHint()
return QSize(120, 58)
def destroyEditor(self, editor: QWidget, index: QModelIndex) -> None: # noqa: N802
if isinstance(editor, BeamlineStatePill):
name = editor.state_name
if name and self._manager._state_pills.get(name) is editor:
self._manager._state_pills.pop(name, None)
editor.cleanup()
super().destroyEditor(editor, index)
class _BeamlineStateListView(QListView):
"""List view using persistent pill editors."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("beamline_state_pill_view")
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setFrameShape(QListView.Shape.NoFrame)
self.setSpacing(6)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setStyleSheet(
"QListView#beamline_state_pill_view {"
"background: transparent;"
"border: none;"
"}"
"QListView#beamline_state_pill_view::item {"
"background: transparent;"
"border: none;"
"padding: 0;"
"}"
"QListView#beamline_state_pill_view::item:selected {"
"background: transparent;"
"border: none;"
"}"
)
class BeamlineStateManager(BECWidget, QWidget):
"""
Widget displaying and managing all BEC beamline states.
The manager subscribes to ``MessageEndpoints.available_beamline_states()`` and creates,
updates, or removes child ``BeamlineStatePill`` widgets as the set of configured states changes.
"""
PLUGIN = True
ICON_NAME = "format_list_bulleted"
USER_ACCESS = [
"clear_filters",
"collapse_all",
"state_summary",
"remove",
"attach",
"detach",
"screenshot",
]
def __init__(
self,
parent: QWidget | None = None,
client=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
idle_card_background: bool = False,
**kwargs,
) -> None:
super().__init__(
parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs
)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._state_pills: dict[str, BeamlineStatePill] = {}
self._state_configs: dict[str, messages.BeamlineStateConfig] = {}
self._state_order: list[str] = []
self._selected_statuses: set[str] | None = None
self._selected_devices: set[str] | None = None
self._device_filter_text = ""
self._hidden_expanded = False
self._idle_card_background = False
self.idle_card_background = idle_card_background
self._empty_label = QLabel(
"No beamline states available.\n Add new state from toolbar or CLI.", self
)
self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._toolbar = self._create_toolbar()
self._model = _BeamlineStateListModel(self)
self._view = _BeamlineStateListView(self)
self._delegate = _BeamlineStatePillDelegate(self)
self._view.setModel(self._model)
self._view.setItemDelegate(self._delegate)
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(6)
layout.addWidget(self._toolbar)
layout.addWidget(self._empty_label)
layout.addWidget(self._view, 1)
self._hidden_summary = QToolButton(self)
self._hidden_summary.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
self._hidden_summary.setCheckable(True)
self._hidden_summary.toggled.connect(self._toggle_hidden_states)
layout.addWidget(self._hidden_summary)
self.setLayout(layout)
self.bec_dispatcher.connect_slot(
self.update_available_states, MessageEndpoints.available_beamline_states()
)
self.refresh_states()
self._refresh_hidden_summary()
@SafeProperty(bool, default=False)
def idle_card_background(self) -> bool:
"""
Whether idle collapsed pills keep the status-tinted card background.
"""
return self._idle_card_background
@idle_card_background.setter
def idle_card_background(self, enabled: bool) -> None:
self._idle_card_background = enabled
for pill in self._state_pills.values():
pill.idle_card_background = self._idle_card_background
def set_idle_card_background(self, enabled: bool) -> None:
"""Set whether idle collapsed pills keep the status-tinted card background."""
self.idle_card_background = enabled
def _create_toolbar(self) -> ModularToolBar:
toolbar = ModularToolBar(parent=self)
add_state = MaterialIconAction("add", "Add beamline state", filled=True, parent=self)
filter_states = MaterialIconAction(
"filter_alt", "Filter displayed state status", filled=True, parent=self
)
filter_devices = MaterialIconAction(
"devices", "Filter displayed devices", filled=True, parent=self
)
clear_filters = MaterialIconAction(
"filter_alt_off", "Clear beamline state filters", filled=True, parent=self
)
collapse_all = MaterialIconAction(
"collapse_all", "Collapse all states", filled=True, parent=self
)
add_state.action.triggered.connect(self.open_add_state_dialog)
filter_states.action.triggered.connect(self.open_status_filter_dialog)
filter_devices.action.triggered.connect(self.open_device_filter_dialog)
clear_filters.action.triggered.connect(self.clear_filters)
collapse_all.action.triggered.connect(self.collapse_all)
toolbar.components.add_safe("add_state", add_state)
toolbar.components.add_safe("filter_states", filter_states)
toolbar.components.add_safe("filter_devices", filter_devices)
toolbar.components.add_safe("clear_filters", clear_filters)
toolbar.components.add_safe("collapse_all", collapse_all)
bundle = ToolbarBundle("beamline_state_manager", toolbar.components)
bundle.add_action("add_state")
bundle.add_action("filter_states")
bundle.add_action("filter_devices")
bundle.add_action("clear_filters")
bundle.add_action("collapse_all")
toolbar.add_bundle(bundle)
toolbar.show_bundles(["beamline_state_manager"])
return toolbar
@SafeSlot(str)
def apply_theme(self, _theme: str) -> None:
colors = BeamlineStatePill._state_colors("unknown")
self.setStyleSheet(
"BeamlineStateManager { border: none; }"
"QToolButton#hidden_states_summary {"
f"background-color: {colors['background']};"
f"border: 1px solid {colors['border']};"
"border-radius: 6px;"
"padding: 6px;"
"text-align: left;"
"}"
)
for pill in self._state_pills.values():
pill.apply_theme(_theme)
self._refresh_hidden_summary()
@SafeSlot()
def open_add_state_dialog(self) -> None:
dialog = AddBeamlineStateDialog(self, client=self.client)
config = None
try:
accepted = dialog.exec() == QDialog.Accepted
if accepted:
config = dialog.config_result
finally:
dialog.cleanup()
dialog.deleteLater()
if config is None:
return
try:
self.client.beamline_states.add(config)
except Exception as exc:
QMessageBox.warning(self, "Cannot Add State", str(exc))
@SafeSlot()
def open_status_filter_dialog(self) -> None:
dialog = StatusFilterDialog(self._selected_statuses, self)
if dialog.exec() != QDialog.Accepted:
return
self._selected_statuses = dialog.selected_statuses()
self._apply_filters()
@SafeSlot()
def open_device_filter_dialog(self) -> None:
devices = sorted(
{
device
for state in self._state_configs.values()
if (device := self._state_device(state)) is not None
}
)
dialog = DeviceFilterDialog(devices, self._selected_devices, self._device_filter_text, self)
if dialog.exec() != QDialog.Accepted:
return
self._selected_devices = dialog.selected_devices()
self._device_filter_text = dialog.filter_text()
self._apply_filters()
@SafeSlot()
def clear_filters(self) -> None:
self._selected_statuses = None
self._selected_devices = None
self._device_filter_text = ""
self._hidden_expanded = False
self._apply_filters()
@SafeSlot()
def collapse_all(self) -> None:
"""Collapse the settings panel of all displayed state pills."""
for pill in self._state_pills.values():
pill.set_expanded(False)
def state_summary(self) -> dict[str, dict[str, str]]:
"""
Return all beamline states (including filtered ones) with their current status and label.
Returns:
dict: Mapping of state name to a dictionary with ``status`` and ``label`` keys.
"""
return {
name: {"status": pill._status, "label": pill._label}
for name, pill in self._state_pills.items()
}
@SafeSlot()
def refresh_states(self) -> None:
"""Fetch the latest cached available beamline states and update the list immediately."""
msg = self.client.connector.get_last(
MessageEndpoints.available_beamline_states(), key="data"
)
if msg is not None:
self.update_available_states(msg.content, msg.metadata)
@SafeSlot(dict, dict)
def update_available_states(
self, content: dict[str, Any], _metadata: dict[str, Any] | None = None
) -> None:
"""Update the displayed pills from ``AvailableBeamlineStatesMessage`` content."""
expanded_names = {name for name, pill in self._state_pills.items() if pill.is_expanded()}
state_configs: list[messages.BeamlineStateConfig] = content.get("states", [])
if state_configs == list(self._state_configs.values()):
self._apply_filters()
return
self._state_configs = {state.name: state for state in state_configs}
self._state_order = [state.name for state in state_configs]
self._model.set_states(state_configs)
self._open_persistent_editors(expanded_names)
self._apply_filters()
def _open_persistent_editors(self, expanded_names: set[str] | None = None) -> None:
expanded_names = expanded_names or set()
for row in range(self._model.rowCount()):
index = self._model.index(row, 0)
self._view.openPersistentEditor(index)
name = str(index.data(_BeamlineStateListModel.NameRole))
pill = self._state_pills.get(name)
if pill is not None:
pill.set_expanded(name in expanded_names)
self._sync_pill_item_size(name)
def _apply_filters(self) -> None:
visible_names = []
hidden_names = []
for name in self._state_order:
if self._is_state_visible(name):
visible_names.append(name)
else:
hidden_names.append(name)
visible_set = set(visible_names)
show_hidden = self._hidden_expanded and bool(hidden_names)
for row, name in enumerate(self._state_order):
hidden_by_filter = name not in visible_set
self._view.setRowHidden(row, hidden_by_filter and not show_hidden)
self._sync_pill_item_size(name)
self._empty_label.setVisible(
not visible_names and not (self._hidden_expanded and hidden_names)
)
self._view.setVisible(bool(visible_names) or (self._hidden_expanded and bool(hidden_names)))
self._refresh_hidden_summary(hidden_count=len(hidden_names))
def _sync_pill_item_size(self, name: str) -> None:
index = self._model.index_for_name(name)
if not index.isValid():
return
self._model.dataChanged.emit(index, index, [Qt.ItemDataRole.SizeHintRole])
self._view.update(index)
def _is_state_visible(self, name: str) -> bool:
pill = self._state_pills.get(name)
if self._selected_statuses is not None and (
pill is None or pill._status not in self._selected_statuses
):
return False
device = self._state_device(self._state_configs.get(name))
if self._selected_devices is not None and device not in self._selected_devices:
return False
tokens = [
token.strip().casefold()
for token in self._device_filter_text.split(",")
if token.strip()
]
if tokens:
if device is None:
return False
device_lower = device.casefold()
if not any(token in device_lower for token in tokens):
return False
return True
@SafeSlot(str, str, str)
def _on_pill_state_changed(self, _name: str, _status: str, _label: str) -> None:
if self._selected_statuses is not None:
self._apply_filters()
@SafeSlot(str, object)
def _update_state_parameters(
self, state_name: str, config: bl_states.BeamlineStateConfig
) -> None:
state_client = getattr(self.client.beamline_states, state_name, None)
if state_client is None:
QMessageBox.warning(
self, "Cannot Update State", f"Beamline state '{state_name}' is not available."
)
return
try:
state_client.update_parameters(**config.model_dump(exclude={"name"}))
except Exception as exc:
QMessageBox.warning(self, "Cannot Update State", str(exc))
return
pill = self._state_pills.get(state_name)
if pill is not None:
pill.mark_current_settings_clean()
@SafeSlot(str)
def _remove_state_requested(self, state_name: str) -> None:
reply = QMessageBox.question(
self,
"Remove Beamline State",
f"Remove beamline state '{state_name}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
try:
self.client.beamline_states.delete(state_name)
except Exception as exc:
QMessageBox.warning(self, "Cannot Remove State", str(exc))
@SafeSlot(bool)
def _toggle_hidden_states(self, checked: bool) -> None:
self._hidden_expanded = bool(checked)
self._apply_filters()
def _refresh_hidden_summary(self, hidden_count: int | None = None) -> None:
if hidden_count is None:
hidden_count = sum(1 for name in self._state_order if not self._is_state_visible(name))
self._hidden_summary.setObjectName("hidden_states_summary")
self._hidden_summary.setVisible(hidden_count > 0)
self._hidden_summary.setChecked(self._hidden_expanded and hidden_count > 0)
icon_name = "expand_less" if self._hidden_expanded else "expand_more"
self._hidden_summary.setIcon(material_icon(icon_name, convert_to_pixmap=False))
suffix = "state is" if hidden_count == 1 else "states are"
action = "Hide" if self._hidden_expanded else "Show"
self._hidden_summary.setText(
f"{hidden_count} {suffix} hidden by filters. {action} hidden states."
)
@staticmethod
def _state_device(state: messages.BeamlineStateConfig | None) -> str | None:
device = state.parameters.get("device") if state is not None else None
return str(device) if device else None
def cleanup(self) -> None:
self.bec_dispatcher.disconnect_slot(
self.update_available_states, MessageEndpoints.available_beamline_states()
)
for row in range(self._model.rowCount()):
self._view.closePersistentEditor(self._model.index(row, 0))
for pill in list(self._state_pills.values()):
pill.cleanup()
pill.deleteLater()
self._state_pills.clear()
self._toolbar.components.cleanup()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
apply_theme("dark")
window = QWidget()
window.setWindowTitle("Beamline States")
layout = QVBoxLayout(window)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
theme_row = QHBoxLayout()
theme_row.addStretch(1)
theme_row.addWidget(DarkModeButton(parent=window))
layout.addLayout(theme_row)
layout.addWidget(BeamlineStateManager(parent=window), 1)
window.resize(760, 480)
window.show()
sys.exit(app.exec())
@@ -0,0 +1 @@
{'files': ['beamline_state_pill.py']}
@@ -0,0 +1,57 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.services.beamline_states.beamline_state_manager import BeamlineStateManager
DOM_XML = """
<ui language='c++'>
<widget class='BeamlineStateManager' name='beamline_state_manager'>
</widget>
</ui>
"""
class BeamlineStateManagerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = BeamlineStateManager(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Services"
def icon(self):
return designer_material_icon(BeamlineStateManager.ICON_NAME)
def includeFile(self):
return "beamline_state_manager"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BeamlineStateManager"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()
@@ -0,0 +1,625 @@
from __future__ import annotations
from typing import Any
from bec_lib import bl_states, messages
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QColor, QMouseEvent, QPalette
from qtpy.QtWidgets import (
QApplication,
QFormLayout,
QGraphicsDropShadowEffect,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QSizePolicy,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors, get_accent_colors, get_theme_name, rgba, theme_color
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.forms_from_types.pydantic_widget_form import (
OptionalValueWidget,
PydanticWidgetForm,
)
from bec_widgets.widgets.services.beamline_states.dialogs import (
BEAMLINE_STATE_STATUS_LABELS,
SUPPORTED_BEAMLINE_STATES,
)
class _BeamlineStatePillHeader(QWidget):
"""Header surface responsible for pill click gestures."""
clicked = Signal()
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit()
event.accept()
return
super().mousePressEvent(event)
class BeamlineStatePill(BECWidget, QWidget):
"""
Compact widget showing one BEC beamline state.
The pill subscribes to ``MessageEndpoints.beamline_state(state_name)`` and updates whenever
a ``BeamlineStateMessage`` is published for that state.
"""
PLUGIN = False
RPC = False
state_changed = Signal(str, str, str)
update_requested = Signal(str, object)
remove_requested = Signal(str)
row_height_changed = Signal()
_STATUS_LABELS = BEAMLINE_STATE_STATUS_LABELS
_STATUS_ICONS = {
"valid": "check_circle",
"invalid": "cancel",
"warning": "warning",
"unknown": "help",
}
def __init__(
self,
parent: QWidget | None = None,
state_name: str | None = None,
client=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
**kwargs,
) -> None:
super().__init__(
parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs
)
self.setObjectName("BeamlineStatePill")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self._state_name: str | None = None
self._state_config: messages.BeamlineStateConfig | None = None
self._status = "unknown"
self._label = "No state information available."
self._expanded = False
self._idle_card_background = False
self._populating_settings = False
self._settings_baseline: dict[str, Any] = {}
self._settings_dirty_fields: set[str] = set()
self._settings_form_stale = True
self._init_ui(state_name)
def _init_ui(self, state_name: str | None = None) -> None:
self._shadow = QGraphicsDropShadowEffect(self)
self._shadow.setBlurRadius(18)
self._shadow.setOffset(0, 2)
self._shadow.setColor(QColor(0, 0, 0, 120))
self._shadow.setEnabled(False)
self.setGraphicsEffect(self._shadow)
self._header = _BeamlineStatePillHeader(self)
self._header.setObjectName("beamline_state_header")
self._header.setCursor(Qt.CursorShape.PointingHandCursor)
self._header.clicked.connect(self._toggle_expanded)
self._stripe = QWidget(self)
self._stripe.setObjectName("beamline_state_stripe")
self._stripe.setFixedWidth(4)
self._stripe.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
self._stripe.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self._icon_label = QLabel(self)
self._icon_label.setObjectName("beamline_state_icon")
self._icon_label.setFixedSize(32, 32)
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._icon_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self._name_label = QLabel(self)
self._name_label.setObjectName("beamline_state_name")
self._name_label.setTextFormat(Qt.TextFormat.PlainText)
self._name_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self._status_label = QLabel(self)
self._status_label.setObjectName("beamline_state_status")
self._status_label.setTextFormat(Qt.TextFormat.PlainText)
self._status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self._detail_label = QLabel(self)
self._detail_label.setObjectName("beamline_state_detail")
self._detail_label.setTextFormat(Qt.TextFormat.PlainText)
self._detail_label.setWordWrap(True)
self._detail_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self._expand_button = QToolButton(self)
self._expand_button.setObjectName("beamline_state_expand")
self._expand_button.setAutoRaise(True)
self._expand_button.setCursor(Qt.CursorShape.PointingHandCursor)
self._expand_button.clicked.connect(self._toggle_expanded)
text_layout = QVBoxLayout()
text_layout.setContentsMargins(0, 0, 0, 0)
text_layout.setSpacing(1)
text_layout.addWidget(self._name_label)
text_layout.addWidget(self._detail_label)
header_layout = QHBoxLayout(self._header)
header_layout.setContentsMargins(10, 8, 12, 8)
header_layout.setSpacing(10)
header_layout.addWidget(self._stripe)
header_layout.addWidget(self._icon_label)
header_layout.addLayout(text_layout, 1)
header_layout.addWidget(self._status_label, 0, Qt.AlignmentFlag.AlignRight)
header_layout.addWidget(self._expand_button)
self._settings = QWidget(self)
self._settings.setObjectName("beamline_state_settings")
self._settings.setVisible(False)
self._state_type_value = QLabel(self._settings)
self._config_form: PydanticWidgetForm | None = None
self._config_form_host = QVBoxLayout()
self._config_form_host.setContentsMargins(0, 0, 0, 0)
self._config_form_host.setSpacing(0)
button_layout = QHBoxLayout()
button_layout.setContentsMargins(0, 0, 0, 0)
button_layout.setSpacing(8)
self._update_button = QPushButton("Update", self._settings)
self._update_button.setIcon(material_icon("save", convert_to_pixmap=False))
self._revert_button = QPushButton("Revert", self._settings)
self._revert_button.setIcon(material_icon("undo", convert_to_pixmap=False))
self._remove_button = QPushButton("Remove", self._settings)
self._remove_button.setObjectName("beamline_state_remove_button")
self._remove_button.setIcon(material_icon("delete", convert_to_pixmap=False))
self._update_button.clicked.connect(self._emit_update_requested)
self._revert_button.clicked.connect(self._revert_settings)
self._remove_button.clicked.connect(self._emit_remove_requested)
button_layout.addWidget(self._update_button)
button_layout.addWidget(self._revert_button)
button_layout.addWidget(self._remove_button)
button_layout.addStretch(1)
self._settings_form = QFormLayout()
self._settings_form.setContentsMargins(0, 0, 0, 0)
self._settings_form.setHorizontalSpacing(10)
self._settings_form.setVerticalSpacing(8)
self._settings_form.setFieldGrowthPolicy(
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow
)
self._settings_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self._settings_form.addRow("Type", self._state_type_value)
settings_layout = QVBoxLayout(self._settings)
settings_layout.setContentsMargins(12, 8, 12, 12)
settings_layout.setSpacing(8)
settings_layout.addLayout(self._settings_form)
settings_layout.addLayout(self._config_form_host)
settings_layout.addLayout(button_layout)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self._header)
layout.addWidget(self._settings)
self.setLayout(layout)
self._settings.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.set_state_name(state_name)
self._update_button.setEnabled(False)
self._revert_button.setEnabled(False)
@SafeProperty(str, default=None)
def state_name(self) -> str | None:
"""Name of the BEC beamline state displayed by this pill."""
return self._state_name
@state_name.setter
def state_name(self, state_name: str | None) -> None:
self.set_state_name(state_name)
def set_state_name(self, state_name: str | None) -> None:
"""
Set the BEC beamline state this pill displays.
Args:
state_name: State name as published by ``AvailableBeamlineStatesMessage``.
"""
if state_name == self._state_name:
return
if self._state_name is not None:
self.bec_dispatcher.disconnect_slot(
self.update_state, MessageEndpoints.beamline_state(self._state_name)
)
self._state_name = state_name
self._name_label.setText(state_name or "Beamline state")
if self._state_name is None:
self._set_visual_state("unknown", "No beamline state selected.")
return
self._set_visual_state("unknown", "No state information available.")
self._refresh_latest_state()
self.bec_dispatcher.connect_slot(
self.update_state, MessageEndpoints.beamline_state(self._state_name)
)
def set_state_config(self, state_config: messages.BeamlineStateConfig | None) -> None:
"""Set the editable BEC state configuration displayed by the expanded panel."""
self._state_config = state_config
self._settings_form_stale = True
if self._config_form is not None:
self._populate_settings()
self.mark_current_settings_clean()
@SafeProperty(bool, default=False)
def idle_card_background(self) -> bool:
"""
Whether idle collapsed pills keep the status-tinted card background.
"""
return self._idle_card_background
@idle_card_background.setter
def idle_card_background(self, enabled: bool) -> None:
self._idle_card_background = enabled
self._apply_visual_state()
def set_idle_card_background(self, enabled: bool) -> None:
"""Set whether idle collapsed pills keep the status-tinted card background."""
self.idle_card_background = enabled
def _refresh_latest_state(self) -> None:
if self._state_name is None:
return
msg = self.client.connector.get_last(
MessageEndpoints.beamline_state(self._state_name), key="data"
)
if msg is not None:
self.update_state(msg.content, msg.metadata)
@SafeSlot(dict, dict)
def update_state(
self, content: dict[str, Any], _metadata: dict[str, Any] | None = None
) -> None:
"""
Update this pill from a ``BeamlineStateMessage`` content dictionary.
"""
name = content.get("name")
if self._state_name is not None and name and name != self._state_name:
return
status = str(content.get("status", "unknown")).lower()
label = str(content.get("label", "No state information available."))
self._set_visual_state(status, label)
self.state_changed.emit(self._state_name or str(name or ""), status, label)
@SafeSlot(str)
def apply_theme(self, _theme: str) -> None:
self._apply_visual_state()
def _set_visual_state(self, status: str, label: str) -> None:
status = status if status in self._STATUS_LABELS else "unknown"
self._status = status
self._label = label
self._apply_visual_state()
def _apply_visual_state(self) -> None:
colors = self._state_colors(self._status)
accent = colors["accent"]
on_accent = colors["on_accent"]
active_card = self._expanded
border = colors["border"] if self._idle_card_background else "transparent"
background = colors["background"] if self._idle_card_background else "transparent"
card_gradient = (
"qlineargradient("
"x1:0, y1:0, x2:1, y2:0, "
f"stop:0 {colors['gradient_accent']}, "
f"stop:{colors['gradient_stop']} {colors['card_background']}, "
f"stop:1 {colors['card_background']}"
")"
)
if active_card:
background = card_gradient
border = colors["card_border"]
hover_background = card_gradient
self._shadow.setColor(QColor(colors["shadow"]))
self._shadow.setBlurRadius(int(colors["shadow_blur"]))
self._shadow.setOffset(0, int(colors["shadow_y_offset"]))
self._shadow.setEnabled(active_card)
icon_name = self._STATUS_ICONS[self._status]
self._icon_label.setPixmap(
material_icon(icon_name, size=(20, 20), color=on_accent, filled=True)
)
expand_icon = "expand_less" if self._expanded else "expand_more"
self._expand_button.setIcon(material_icon(expand_icon, convert_to_pixmap=False))
self._status_label.setText(self._STATUS_LABELS[self._status])
self._detail_label.setText(self._label)
self.setToolTip(self._label)
self.setStyleSheet(
"#BeamlineStatePill {"
f"background: {background};"
f"border: 1px solid {border};"
f"border-radius: {'12px' if active_card else '8px'};"
"}"
"#BeamlineStatePill:hover {"
f"background: {hover_background};"
f"border: 1px solid {colors['card_border']};"
"border-radius: 12px;"
"}"
"QWidget#beamline_state_header {"
"background: transparent;"
"}"
"QWidget#beamline_state_stripe {"
f"background-color: {accent};"
"border-radius: 2px;"
"}"
"QLabel#beamline_state_icon {"
f"background-color: {accent};"
"border-radius: 16px;"
"}"
"QLabel#beamline_state_name {"
f"color: {colors['foreground']};"
"font-weight: 600;"
"}"
"QLabel#beamline_state_status {"
f"color: {accent};"
"font-weight: 700;"
"font-size: 13px;"
"}"
"QLabel#beamline_state_detail {"
f"color: {colors['muted']};"
"font-size: 11px;"
"}"
"QWidget#beamline_state_settings {"
"background: transparent;"
f"border-top: 1px solid {colors['border']};"
"}"
'*[beamlineStateDirty="true"] {'
f"background-color: {colors['dirty_background']};"
f"border: 1px solid {colors['dirty_border']};"
"border-radius: 4px;"
"}"
"QPushButton#beamline_state_remove_button {"
"background-color: #cc181e;"
"border: 1px solid #cc181e;"
"color: white;"
"border-radius: 4px;"
"padding: 4px 10px;"
"}"
"QPushButton#beamline_state_remove_button:hover {"
"background-color: #a91419;"
"border-color: #a91419;"
"}"
)
@SafeSlot()
def _toggle_expanded(self) -> None:
self.set_expanded(not self._expanded)
def is_expanded(self) -> bool:
"""Return whether the editable settings panel is expanded."""
return self._expanded
def set_expanded(self, expanded: bool) -> None:
"""
Set the editable settings panel expanded state.
The settings form is built on demand when the panel expands and released again on
collapse, so collapsed pills do not keep live device/signal widgets and their BEC
subscriptions around. Unsaved edits are discarded on collapse.
"""
expanded = bool(expanded)
if expanded == self._expanded:
return
if expanded:
self._ensure_settings_form_current()
self._expanded = expanded
self._settings.setVisible(expanded)
if not expanded:
self._release_config_form()
self._apply_visual_state()
self.row_height_changed.emit()
def _ensure_config_form(
self, config_class: type[bl_states.BeamlineStateConfig] = bl_states.DeviceStateConfig
) -> PydanticWidgetForm:
if self._config_form is None:
self._config_form = PydanticWidgetForm(
config_class, parent=self._settings, client=self.client, read_only_fields={"name"}
)
self._config_form.changed.connect(self._update_settings_dirty_state)
self._config_form_host.addWidget(self._config_form)
return self._config_form
def _ensure_settings_form_current(self) -> PydanticWidgetForm:
if self._settings_form_stale:
self._populate_settings()
self.mark_current_settings_clean()
return self._ensure_config_form()
def _release_config_form(self) -> None:
if self._config_form is None:
return
self._config_form_host.removeWidget(self._config_form)
self._config_form.cleanup()
self._config_form.setParent(None)
self._config_form.deleteLater()
self._config_form = None
self._settings_baseline = {}
self._settings_form_stale = True
self._update_settings_dirty_state()
def _populate_settings(self) -> None:
self._populating_settings = True
try:
state_type = self._state_config.state_type if self._state_config is not None else ""
config_class = None
for state_class in SUPPORTED_BEAMLINE_STATES:
if state_type in {state_class.__name__, state_class.CONFIG_CLASS.state_type}:
config_class = state_class.CONFIG_CLASS
break
if config_class is None:
raise ValueError(f"Unsupported beamline state type '{state_type}'.")
config_form = self._ensure_config_form(config_class)
if config_form.model is not config_class:
config_form.set_model(config_class)
self._state_type_value.setText(state_type or "-")
config_form.set_partial_data(self._state_data_for_form(config_class))
self._settings_form_stale = False
finally:
self._populating_settings = False
self._update_settings_dirty_state()
def edited_config(self) -> bl_states.BeamlineStateConfig:
"""Return the validated config currently represented by the expanded settings panel."""
config = self._ensure_settings_form_current().model_instance()
return config # type: ignore[return-value]
def mark_current_settings_clean(self) -> None:
"""Mark the current editor values as saved."""
config_form = self._ensure_config_form()
self._settings_baseline = config_form.raw_editable_data()
config_form.mark_clean()
self._update_settings_dirty_state()
@SafeSlot()
def _revert_settings(self) -> None:
self._populating_settings = True
try:
self._ensure_config_form().set_partial_data(self._settings_baseline)
finally:
self._populating_settings = False
self._update_settings_dirty_state()
def _update_settings_dirty_state(self) -> None:
if self._populating_settings:
return
if self._config_form is None:
self._settings_dirty_fields = set()
self._update_button.setEnabled(False)
self._revert_button.setEnabled(False)
return
self._settings_dirty_fields = self._config_form.dirty_fields() - {"name"}
has_changes = bool(self._settings_dirty_fields)
self._update_button.setEnabled(has_changes)
self._revert_button.setEnabled(has_changes)
self._apply_dirty_field_highlights()
def _apply_dirty_field_highlights(self) -> None:
if self._config_form is None:
return
for name, widget in self._config_form.widgets.items():
self._set_dirty_property(widget, name in self._settings_dirty_fields)
@staticmethod
def _set_dirty_property(widget: QWidget, dirty: bool) -> None:
widgets = [widget]
if isinstance(widget, OptionalValueWidget):
widgets.append(widget.value_widget)
if widget.value_widget.parentWidget() is not None:
widgets.append(widget.value_widget.parentWidget())
for target in widgets:
if target.property("beamlineStateDirty") == dirty:
continue
target.setProperty("beamlineStateDirty", dirty)
target.style().unpolish(target)
target.style().polish(target)
target.update()
@SafeSlot()
def _emit_update_requested(self) -> None:
if self._state_name is None:
return
if not self._settings_dirty_fields:
return
try:
config = self.edited_config()
except ValueError as exc:
QMessageBox.warning(self, "Invalid Beamline State", str(exc))
return
self.update_requested.emit(self._state_name, config)
@SafeSlot()
def _emit_remove_requested(self) -> None:
if self._state_name is None:
return
self.remove_requested.emit(self._state_name)
def _state_data_for_form(
self, config_class: type[bl_states.BeamlineStateConfig]
) -> dict[str, Any]:
data: dict[str, Any] = {}
parameters = self._state_config.parameters if self._state_config is not None else {}
for name in config_class.model_fields:
if name == "name":
data[name] = self._state_name
elif name in parameters:
data[name] = parameters[name]
return data
@staticmethod
def _state_colors(status: str) -> dict[str, str]:
app = QApplication.instance()
palette = app.palette() if app is not None else QPalette()
theme = getattr(app, "theme", None) if app is not None else None
light_theme = get_theme_name() == "light"
accents = get_accent_colors()
card_bg = theme_color(theme, "CARD_BG", palette.window().color())
border = theme_color(theme, "BORDER", palette.mid().color())
foreground = theme_color(theme, "FG", palette.text().color())
on_primary = theme_color(theme, "ON_PRIMARY", QColor("#ffffff"))
warning = accents.warning
accent = {
"valid": accents.success,
"invalid": accents.emergency,
"warning": warning,
"unknown": accents.default,
}.get(status, accents.default)
gradient_alpha = 18 if light_theme else 62
gradient_stop = "0.38" if light_theme else "0.62"
background_mix = 0.0 if light_theme else 0.10
card_border_mix = 0.34 if light_theme else 0.45
border_mix = 0.34 if light_theme else 0.35
return {
"accent": accent.name(),
"on_accent": on_primary.name(),
"card_background": card_bg.name(),
"card_border": Colors._blend(border, accent, card_border_mix).name(),
"gradient_accent": rgba(accent, gradient_alpha),
"gradient_stop": gradient_stop,
"background": Colors._blend(card_bg, accent, background_mix).name(),
"border": Colors._blend(border, accent, border_mix).name(),
"dirty_background": Colors._blend(
card_bg, warning, 0.12 if light_theme else 0.18
).name(),
"dirty_border": Colors._blend(border, warning, 0.70).name(),
"foreground": foreground.name(),
"muted": Colors._blend(card_bg, foreground, 0.66).name(),
"shadow": "#00000024" if light_theme else "#00000078",
"shadow_blur": "24" if light_theme else "18",
"shadow_y_offset": "3" if light_theme else "2",
}
def cleanup(self) -> None:
if self._state_name is not None:
self.bec_dispatcher.disconnect_slot(
self.update_state, MessageEndpoints.beamline_state(self._state_name)
)
if self._config_form is not None:
self._config_form.cleanup()
super().cleanup()
@@ -0,0 +1,263 @@
from __future__ import annotations
import slugify
from bec_lib import bl_states
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLineEdit,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.forms_from_types.pydantic_widget_form import PydanticWidgetForm
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
BEAMLINE_STATE_STATUS_LABELS = {
"valid": "VALID",
"invalid": "INVALID",
"warning": "WARNING",
"unknown": "UNKNOWN",
}
SUPPORTED_BEAMLINE_STATES: tuple[type[bl_states.BeamlineState], ...] = (
bl_states.DeviceWithinLimitsState,
bl_states.ShutterState,
)
class AddBeamlineStateDialog(QDialog):
"""Dialog for creating supported beamline state configurations."""
def __init__(self, parent: QWidget | None = None, client=None) -> None:
super().__init__(parent=parent)
self.setWindowTitle("Add Beamline State")
self._cleaned_up = False
self._client = client
self._config: bl_states.BeamlineStateConfig | None = None
self._auto_generated_name: str | None = None
self._type_combo = QComboBox(self)
for state_class in SUPPORTED_BEAMLINE_STATES:
self._type_combo.addItem(state_class.__name__, state_class)
self._type_combo.currentIndexChanged.connect(self._update_config_form)
self._form = QFormLayout()
self._form.addRow("State type", self._type_combo)
self._config_form_host = QVBoxLayout()
self._config_form: PydanticWidgetForm | None = None
self._buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self
)
self._buttons.accepted.connect(self.accept)
self._buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addLayout(self._form)
layout.addLayout(self._config_form_host)
layout.addWidget(self._buttons)
self.setLayout(layout)
self._update_config_form()
self._fit_to_contents()
def config(self) -> bl_states.BeamlineStateConfig:
state_class = self._selected_state_class()
config_class = state_class.CONFIG_CLASS
name = self._state_name()
data = self._config_form.get_data()
data["name"] = name
return config_class.model_validate(data)
def accept(self) -> None:
try:
self._config = self.config()
except Exception as exc:
QMessageBox.warning(self, "Invalid Beamline State", str(exc))
return
super().accept()
@property
def config_result(self) -> bl_states.BeamlineStateConfig:
if self._config is None:
raise RuntimeError("Beamline state dialog was not accepted with a valid config.")
return self._config
def cleanup(self) -> None:
if self._cleaned_up:
return
self._cleaned_up = True
if self._config_form is not None:
self._config_form.cleanup()
self._config_form.close()
self._config_form.deleteLater()
def closeEvent(self, event) -> None: # noqa: N802
self.cleanup()
super().closeEvent(event)
@SafeSlot(str)
def _on_valid_device_selected(self, device: str) -> None:
if self._cleaned_up:
return
name_widget = self._config_form.input_widget("name")
current_name = name_widget.text().strip()
if current_name and current_name != self._auto_generated_name:
return
suffix = slugify.slugify(
pascal_to_snake(self._selected_state_class().__name__), separator="_"
)
generated_name = f"{slugify.slugify(device, separator='_')}_{suffix}"
self._auto_generated_name = generated_name
name_widget.setText(generated_name)
@SafeSlot(int)
def _update_config_form(self, _index: int = 0) -> None:
previous_data = self._config_form.raw_data() if self._config_form is not None else {}
if self._config_form is not None:
self._config_form_host.removeWidget(self._config_form)
self._config_form.cleanup()
self._config_form.setParent(None)
self._config_form.deleteLater()
config_class = self._selected_state_class().CONFIG_CLASS
data = {
key: value
for key, value in previous_data.items()
if key in config_class.model_fields and value is not None
}
self._config_form = PydanticWidgetForm(config_class, parent=self, client=self._client)
self._config_form.set_partial_data(data)
self._config_form_host.addWidget(self._config_form)
for device_widget in self._config_form.input_widgets_by_type(DeviceComboBox):
device_widget.device_selected.connect(self._on_valid_device_selected)
self._fit_to_contents()
def _fit_to_contents(self) -> None:
self.setMinimumSize(0, 0)
self.setMaximumSize(16777215, 16777215)
self.layout().activate()
self.setFixedSize(self.sizeHint().expandedTo(self.minimumSizeHint()))
def _selected_state_class(self) -> type[bl_states.BeamlineState]:
state_class = self._type_combo.currentData()
if state_class is None:
raise RuntimeError("No beamline state class selected.")
return state_class
def _state_name(self) -> str:
name_widget = self._config_form.input_widget("name")
raw_name = name_widget.text().strip()
if not raw_name:
raise ValueError("Name is required.")
name = slugify.slugify(raw_name, separator="_")
name_widget.setText(name)
return name
class StatusFilterDialog(QDialog):
"""Dialog for selecting visible beamline state statuses."""
def __init__(self, selected_statuses: set[str] | None, parent: QWidget | None = None) -> None:
super().__init__(parent=parent)
self.setWindowTitle("Filter Beamline State Status")
self._checkboxes: dict[str, QCheckBox] = {}
controls = QHBoxLayout()
select_all = QPushButton("Select all", self)
clear = QPushButton("Clear", self)
select_all.clicked.connect(lambda: self._set_all(True))
clear.clicked.connect(lambda: self._set_all(False))
controls.addWidget(select_all)
controls.addWidget(clear)
controls.addStretch(1)
list_layout = QVBoxLayout()
for status, label in BEAMLINE_STATE_STATUS_LABELS.items():
checkbox = QCheckBox(label, self)
checkbox.setChecked(selected_statuses is None or status in selected_statuses)
self._checkboxes[status] = checkbox
list_layout.addWidget(checkbox)
list_layout.addStretch(1)
box = QGroupBox("Displayed status", self)
box.setLayout(list_layout)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addLayout(controls)
layout.addWidget(box)
layout.addWidget(buttons)
self.setLayout(layout)
def selected_statuses(self) -> set[str] | None:
selected = {status for status, checkbox in self._checkboxes.items() if checkbox.isChecked()}
if selected == set(self._checkboxes):
return None
return selected
def _set_all(self, checked: bool) -> None:
for checkbox in self._checkboxes.values():
checkbox.setChecked(checked)
class DeviceFilterDialog(QDialog):
"""Dialog for filtering beamline states by configured device."""
def __init__(
self,
devices: list[str],
selected_devices: set[str] | None,
device_filter_text: str,
parent: QWidget | None = None,
) -> None:
super().__init__(parent=parent)
self.setWindowTitle("Filter Beamline State Devices")
self._checkboxes: dict[str, QCheckBox] = {}
self._device_text = QLineEdit(self)
self._device_text.setPlaceholderText("Device name or comma-separated names")
self._device_text.setText(device_filter_text)
list_layout = QVBoxLayout()
for device in devices:
checkbox = QCheckBox(device, self)
checkbox.setChecked(selected_devices is not None and device in selected_devices)
self._checkboxes[device] = checkbox
list_layout.addWidget(checkbox)
list_layout.addStretch(1)
box = QGroupBox("Known devices", self)
box.setLayout(list_layout)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addWidget(self._device_text)
layout.addWidget(box)
layout.addWidget(buttons)
self.setLayout(layout)
def selected_devices(self) -> set[str] | None:
selected = {device for device, checkbox in self._checkboxes.items() if checkbox.isChecked()}
return selected or None
def filter_text(self) -> str:
return self._device_text.text().strip()
@@ -0,0 +1,17 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.services.beamline_states.beamline_state_manager_plugin import (
BeamlineStateManagerPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(BeamlineStateManagerPlugin())
if __name__ == "__main__": # pragma: no cover
main()
+6 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "bec_widgets"
version = "3.13.5"
version = "3.15.0"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [
@@ -12,8 +12,8 @@ dependencies = [
"PyJWT~=2.9",
"PySide6==6.9.0",
"PySide6-QtAds==4.4.0",
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
"bec_lib~=3.107,>=3.107.2",
"bec_ipython_client~=3.134", # needed for jupyter console
"bec_lib~=3.134",
"bec_qthemes~=1.0, >=1.3.4",
"black>=26,<27", # needed for bw-generate-cli
"copier~=9.7",
@@ -24,6 +24,7 @@ dependencies = [
"pydantic~=2.0",
"pylsp-bec~=1.2",
"pyqtgraph==0.13.7",
"python-slugify~=8.0",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtmonaco~=0.8, >=0.8.1",
"qtpy~=2.4",
@@ -73,6 +74,8 @@ qtermwidget = ["pyside6_qtermwidget"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
@@ -0,0 +1,73 @@
from __future__ import annotations
import uuid
import pytest
from bec_lib.bl_states import DeviceWithinLimitsStateConfig
# pylint: disable=unused-argument
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
def _delete_state_if_present(bec, state_name: str) -> None:
if hasattr(bec.beamline_states, state_name):
bec.beamline_states.delete(state_name)
@pytest.mark.timeout(100)
def test_beamline_state_manager_adds_updates_and_deletes_state_e2e(
qtbot, bec_client_lib, connected_client_gui_obj
):
"""
Verify the real BEC beamline-state flow is reflected by a BeamlineStateManager
running in the GUI server, accessed through the dock area RPC interface.
"""
gui = connected_client_gui_obj
dock_area = gui.bec
bec = bec_client_lib
dev = bec.device_manager.devices
scans = bec.scans
state_name = f"samx_widget_limits_{uuid.uuid4().hex[:8]}"
config = DeviceWithinLimitsStateConfig(
name=state_name, device="samx", signal="samx", low_limit=0.0, high_limit=10.0, tolerance=1.0
)
manager = dock_area.new("BeamlineStateManager")
qtbot.waitUntil(lambda: manager._gui_id in gui._server_registry, timeout=5000)
def state_entry() -> dict[str, str]:
return manager.state_summary().get(state_name, {})
_delete_state_if_present(bec, state_name)
try:
bec.beamline_states.add(config)
qtbot.waitUntil(lambda: hasattr(bec.beamline_states, state_name), timeout=10000)
qtbot.waitUntil(lambda: state_name in manager.state_summary(), timeout=10000)
scans.umv(dev.samx, 5, relative=False).wait()
qtbot.waitUntil(
lambda: getattr(bec.beamline_states, state_name).get()["status"] == "valid",
timeout=10000,
)
qtbot.waitUntil(lambda: state_entry().get("status") == "valid", timeout=10000)
assert state_entry()["label"] == "Device samx within limits"
scans.umv(dev.samx, 20, relative=False).wait()
qtbot.waitUntil(
lambda: getattr(bec.beamline_states, state_name).get()["status"] == "invalid",
timeout=10000,
)
qtbot.waitUntil(lambda: state_entry().get("status") == "invalid", timeout=10000)
assert state_entry()["label"] == "Device samx out of limits"
bec.beamline_states.delete(state_name)
qtbot.waitUntil(lambda: not hasattr(bec.beamline_states, state_name), timeout=10000)
qtbot.waitUntil(lambda: state_name not in manager.state_summary(), timeout=10000)
finally:
_delete_state_if_present(bec, state_name)
scans.umv(dev.samx, 0, relative=False).wait()
+1 -1
View File
@@ -69,7 +69,7 @@ def create_widget(
return widget
@pytest.mark.timeout(20)
@pytest.mark.timeout(100)
def test_available_widgets(qtbot, connected_client_gui_obj):
"""This test checks that all widgets that are available via gui.available_widgets can be created and removed."""
gui = connected_client_gui_obj
@@ -0,0 +1,588 @@
import shiboken6
from bec_lib import bl_states, messages
from qtpy.QtCore import QCoreApplication, QEvent, Qt
from qtpy.QtWidgets import QMessageBox
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.services.beamline_states import beamline_state_manager as manager_module
from bec_widgets.widgets.services.beamline_states import beamline_state_pill as pill_module
from bec_widgets.widgets.services.beamline_states.beamline_state_manager import BeamlineStateManager
from bec_widgets.widgets.services.beamline_states.beamline_state_pill import BeamlineStatePill
from bec_widgets.widgets.services.beamline_states.dialogs import AddBeamlineStateDialog
from .client_mocks import mocked_client
from .conftest import create_widget
def _state(name: str, state_type: str, parameters: dict | None = None):
return messages.BeamlineStateConfig(
name=name, state_type=state_type, parameters=parameters or {}
)
def _wire_state(
state_class: type[bl_states.BeamlineState], config: bl_states.BeamlineStateConfig
) -> messages.BeamlineStateConfig:
return messages.BeamlineStateConfig(
name=config.name,
state_type=state_class.__name__,
parameters=config.model_dump(exclude={"name"}),
)
def _limits_state(name: str = "limits", **overrides) -> messages.BeamlineStateConfig:
values = {
"device": "samx",
"signal": "samx",
"low_limit": 0.0,
"high_limit": 10.0,
"tolerance": 0.1,
}
values.update(overrides)
config = bl_states.DeviceWithinLimitsState.CONFIG_CLASS(name=name, **values)
return _wire_state(bl_states.DeviceWithinLimitsState, config)
def _shutter_state(
name: str = "shutter_open", device: str = "samy"
) -> messages.BeamlineStateConfig:
config = bl_states.ShutterState.CONFIG_CLASS(name=name, device=device)
return _wire_state(bl_states.ShutterState, config)
def test_beamline_state_pill_updates_from_message(qtbot, mocked_client):
pill = create_widget(qtbot, BeamlineStatePill, state_name="shutter_open", client=mocked_client)
pill.update_state({"name": "shutter_open", "status": "valid", "label": "Shutter is open."}, {})
assert pill._state_name == "shutter_open"
assert pill._name_label.text() == "shutter_open"
assert pill._status_label.text() == "VALID"
assert pill._detail_label.text() == "Shutter is open."
assert not pill._icon_label.pixmap().isNull()
assert pill.toolTip() == "Shutter is open."
def test_beamline_state_pill_ignores_other_states(qtbot, mocked_client):
pill = create_widget(qtbot, BeamlineStatePill, state_name="shutter_open", client=mocked_client)
pill.update_state(
{"name": "other_state", "status": "invalid", "label": "Should be ignored."}, {}
)
assert pill._status_label.text() == "UNKNOWN"
assert pill.toolTip() == "No state information available."
def test_beamline_state_pill_expands_and_emits_updated_limits(qtbot, mocked_client):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
limits_pill.set_state_config(_limits_state())
assert limits_pill._settings.isHidden()
assert limits_pill._config_form is None
assert not limits_pill._update_button.isEnabled()
assert not limits_pill._revert_button.isEnabled()
qtbot.mouseClick(limits_pill._header, Qt.MouseButton.LeftButton)
assert limits_pill._config_form is not None
high_limit = limits_pill._config_form.input_widget("high_limit")
high_limit.setValue(20.0)
assert not limits_pill._settings.isHidden()
assert limits_pill._update_button.isEnabled()
assert limits_pill._revert_button.isEnabled()
assert (
limits_pill._config_form.field_widget("high_limit").property("beamlineStateDirty") is True
)
assert limits_pill._config_form.get_data()["device"] == "samx"
assert limits_pill.edited_config().high_limit == 20.0
with qtbot.waitSignal(limits_pill.update_requested) as signal:
limits_pill._update_button.click()
assert signal.args[0] == "limits"
assert isinstance(signal.args[1], bl_states.DeviceWithinLimitsState.CONFIG_CLASS)
assert signal.args[1].device == "samx"
assert signal.args[1].signal == "samx"
assert signal.args[1].low_limit == 0.0
assert signal.args[1].high_limit == 20.0
assert signal.args[1].tolerance == 0.1
assert not limits_pill._settings.isHidden()
def test_beamline_state_pill_first_expand_uses_config_class_without_rebuild(
qtbot, mocked_client, monkeypatch
):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
set_model_calls = []
original_set_model = pill_module.PydanticWidgetForm.set_model
def set_model_spy(self, model, data=None):
set_model_calls.append(model)
return original_set_model(self, model, data=data)
monkeypatch.setattr(pill_module.PydanticWidgetForm, "set_model", set_model_spy)
limits_pill.set_state_config(_limits_state())
limits_pill.set_expanded(True)
assert limits_pill._config_form is not None
assert set_model_calls == []
def test_beamline_state_pill_reverts_changed_settings(qtbot, mocked_client):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
limits_pill.set_state_config(_limits_state())
limits_pill.set_expanded(True)
assert limits_pill._config_form is not None
low_limit = limits_pill._config_form.input_widget("low_limit")
low_limit.setValue(-5.0)
assert limits_pill._update_button.isEnabled()
assert limits_pill._config_form.field_widget("low_limit").property("beamlineStateDirty") is True
limits_pill._revert_button.click()
assert low_limit.value() == 0.0
assert not limits_pill._update_button.isEnabled()
assert not limits_pill._revert_button.isEnabled()
assert (
limits_pill._config_form.field_widget("low_limit").property("beamlineStateDirty") is False
)
def test_beamline_state_pill_releases_form_on_collapse(qtbot, mocked_client):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
limits_pill.set_state_config(_limits_state())
limits_pill.set_expanded(True)
device_widget = limits_pill._config_form.input_widget("device")
limits_pill.set_expanded(False)
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
assert limits_pill._config_form is None
assert not shiboken6.isValid(device_widget)
limits_pill.set_expanded(True)
assert limits_pill._config_form is not None
assert limits_pill._config_form.input_widget("high_limit").value() == 10.0
assert not limits_pill._update_button.isEnabled()
def test_beamline_state_pill_collapse_discards_unsaved_edits(qtbot, mocked_client):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
limits_pill.set_state_config(_limits_state())
limits_pill.set_expanded(True)
limits_pill._config_form.input_widget("high_limit").setValue(20.0)
assert limits_pill._update_button.isEnabled()
limits_pill.set_expanded(False)
assert limits_pill._config_form is None
limits_pill.set_expanded(True)
assert limits_pill._config_form.input_widget("high_limit").value() == 10.0
assert not limits_pill._update_button.isEnabled()
def test_beamline_state_pill_does_not_override_themed_input_controls(qtbot, mocked_client):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
limits_pill.set_state_config(_limits_state())
limits_pill.set_expanded(True)
stylesheet = limits_pill.styleSheet()
assert "QAbstractSpinBox" not in stylesheet
assert "QComboBox" not in stylesheet
assert "QCheckBox::indicator" not in stylesheet
def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{
"states": [
_state("shutter_open", "ShutterState"),
_state("limits", "DeviceWithinLimitsState"),
]
},
{},
)
assert sorted(beamline_state_manager._state_pills) == ["limits", "shutter_open"]
assert beamline_state_manager._model.rowCount() == 2
assert beamline_state_manager._state_pills["shutter_open"]._name_label.text() == "shutter_open"
assert not beamline_state_manager._empty_label.isVisible()
beamline_state_manager._state_pills["limits"].update_state(
{"name": "limits", "status": "valid", "label": "Within limits."}, {}
)
summary = beamline_state_manager.state_summary()
assert summary["limits"] == {"status": "valid", "label": "Within limits."}
assert summary["shutter_open"]["status"] == "unknown"
beamline_state_manager.update_available_states(
{"states": [_state("limits", "DeviceWithinLimitsState")]}, {}
)
assert sorted(beamline_state_manager._state_pills) == ["limits"]
assert beamline_state_manager._model.rowCount() == 1
def test_beamline_state_manager_ignores_unchanged_available_states(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
content = {"states": [_limits_state()]}
beamline_state_manager.update_available_states(content, {})
pill = beamline_state_manager._state_pills["limits"]
beamline_state_manager.update_available_states(content, {})
assert beamline_state_manager._state_pills["limits"] is pill
assert pill._config_form is None
def test_beamline_state_manager_adds_state_without_recreating_existing_pills(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
limits_state = _limits_state()
shutter_state = _state("shutter_open", "ShutterState")
beamline_state_manager.update_available_states({"states": [limits_state]}, {})
pill = beamline_state_manager._state_pills["limits"]
pill.set_expanded(True)
config_form = pill._config_form
beamline_state_manager.update_available_states({"states": [limits_state, shutter_state]}, {})
assert beamline_state_manager._state_pills["limits"] is pill
assert pill._config_form is config_form
assert pill.is_expanded()
assert sorted(beamline_state_manager._state_pills) == ["limits", "shutter_open"]
def test_beamline_state_manager_header_click_expands_pill_once(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{"states": [_state("limits", "DeviceWithinLimitsState", {"device": "samx"})]}, {}
)
pill = beamline_state_manager._state_pills["limits"]
assert pill._settings.isHidden()
qtbot.mouseClick(pill._header, Qt.MouseButton.LeftButton)
assert not pill._settings.isHidden()
def test_beamline_state_manager_preserves_expanded_pill_on_refresh(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
state = _state("limits", "DeviceWithinLimitsState", {"device": "samx", "high_limit": 10.0})
beamline_state_manager.update_available_states({"states": [state]}, {})
beamline_state_manager._state_pills["limits"].set_expanded(True)
beamline_state_manager.update_available_states({"states": [state]}, {})
assert beamline_state_manager._state_pills["limits"].is_expanded()
assert not beamline_state_manager._state_pills["limits"]._settings.isHidden()
def test_beamline_state_manager_propagates_idle_card_background(qtbot, mocked_client):
idle_card_manager = create_widget(
qtbot, BeamlineStateManager, client=mocked_client, idle_card_background=True
)
idle_card_manager.update_available_states(
{"states": [_state("limits", "DeviceWithinLimitsState", {"device": "samx"})]}, {}
)
assert idle_card_manager._state_pills["limits"]._idle_card_background is True
idle_card_manager.idle_card_background = False
assert idle_card_manager._state_pills["limits"]._idle_card_background is False
def test_beamline_state_manager_filters_status(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{"states": [_shutter_state(), _limits_state()]}, {}
)
assert isinstance(beamline_state_manager._toolbar, ModularToolBar)
assert not beamline_state_manager._toolbar.components.exists("refresh")
beamline_state_manager._state_pills["limits"].update_state(
{"name": "limits", "status": "valid", "label": "Within limits."}, {}
)
beamline_state_manager._state_pills["shutter_open"].update_state(
{"name": "shutter_open", "status": "invalid", "label": "Closed."}, {}
)
beamline_state_manager._selected_statuses = {"valid"}
beamline_state_manager._apply_filters()
assert not beamline_state_manager._hidden_summary.isHidden()
assert "1 state is hidden" in beamline_state_manager._hidden_summary.text()
assert not beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("limits").row()
)
assert beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("shutter_open").row()
)
beamline_state_manager._hidden_summary.click()
assert not beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("shutter_open").row()
)
assert shiboken6.isValid(beamline_state_manager._state_pills["shutter_open"])
beamline_state_manager._hidden_summary.click()
assert beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("shutter_open").row()
)
assert shiboken6.isValid(beamline_state_manager._state_pills["shutter_open"])
def test_beamline_state_manager_status_filter_reacts_to_state_changes(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{"states": [_state("limits", "DeviceWithinLimitsState", {"device": "samx"})]}, {}
)
beamline_state_manager._selected_statuses = {"valid"}
beamline_state_manager._state_pills["limits"].update_state(
{"name": "limits", "status": "valid", "label": "Within limits."}, {}
)
assert beamline_state_manager._hidden_summary.isHidden()
beamline_state_manager._state_pills["limits"].update_state(
{"name": "limits", "status": "invalid", "label": "Out of limits."}, {}
)
assert not beamline_state_manager._hidden_summary.isHidden()
assert beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("limits").row()
)
def test_beamline_state_manager_filters_devices(qtbot, mocked_client, monkeypatch):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{
"states": [
_limits_state(name="samx_limits"),
_limits_state(name="samy_limits", device="samy"),
]
},
{},
)
beamline_state_manager._device_filter_text = "samx"
beamline_state_manager._apply_filters()
assert not beamline_state_manager._hidden_summary.isHidden()
assert "1 state is hidden" in beamline_state_manager._hidden_summary.text()
captured = {}
class FakeDeviceFilterDialog:
def __init__(self, devices, selected_devices, device_filter_text, parent):
captured["devices"] = devices
captured["selected_devices"] = selected_devices
captured["device_filter_text"] = device_filter_text
captured["parent"] = parent
def exec(self):
return 0
monkeypatch.setattr(manager_module, "DeviceFilterDialog", FakeDeviceFilterDialog)
beamline_state_manager.open_device_filter_dialog()
assert captured["devices"] == ["samx", "samy"]
assert captured["device_filter_text"] == "samx"
assert captured["parent"] is beamline_state_manager
def test_beamline_state_manager_collapse_all(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{"states": [_limits_state(), _state("shutter_open", "ShutterState", {"device": "samy"})]},
{},
)
for pill in beamline_state_manager._state_pills.values():
pill.set_expanded(True)
assert all(pill.is_expanded() for pill in beamline_state_manager._state_pills.values())
collapse_action = beamline_state_manager._toolbar.components.get_action("collapse_all")
collapse_action.action.trigger()
assert not any(pill.is_expanded() for pill in beamline_state_manager._state_pills.values())
def test_beamline_state_manager_backend_echo_repopulates_expanded_pill(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states({"states": [_limits_state()]}, {})
pill = beamline_state_manager._state_pills["limits"]
pill.set_expanded(True)
high_limit = pill._config_form.input_widget("high_limit")
high_limit.setValue(20.0)
assert pill._update_button.isEnabled()
beamline_state_manager.update_available_states({"states": [_limits_state(high_limit=20.0)]}, {})
assert pill.is_expanded()
assert high_limit.value() == 20.0
assert not pill._update_button.isEnabled()
assert pill._config_form.dirty_fields() == set()
def test_beamline_state_manager_updates_state_parameters(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states({"states": [_limits_state()]}, {})
class StateClient:
def __init__(self):
self.parameters = None
def update_parameters(self, **kwargs):
self.parameters = kwargs
class StateManager:
def __init__(self):
self.limits = StateClient()
mocked_client.beamline_states = StateManager()
pill = beamline_state_manager._state_pills["limits"]
pill.set_expanded(True)
high_limit = pill._config_form.input_widget("high_limit")
high_limit.setValue(20.0)
assert pill._update_button.isEnabled()
beamline_state_manager._update_state_parameters("limits", pill.edited_config())
assert mocked_client.beamline_states.limits.parameters == {
"device": "samx",
"signal": "samx",
"low_limit": 0.0,
"high_limit": 20.0,
"tolerance": 0.1,
}
assert not pill._update_button.isEnabled()
assert pill._config_form.field_widget("high_limit").property("beamlineStateDirty") is False
def test_beamline_state_manager_removes_state(qtbot, mocked_client, monkeypatch):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
class StateManager:
def __init__(self):
self.deleted = None
def delete(self, state_name):
self.deleted = state_name
mocked_client.beamline_states = StateManager()
monkeypatch.setattr(
QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.Yes
)
beamline_state_manager._remove_state_requested("limits")
assert mocked_client.beamline_states.deleted == "limits"
def test_add_beamline_state_dialog_uses_generated_widgets_and_normalizes_name(qtbot, mocked_client):
add_state_dialog = create_widget(qtbot, AddBeamlineStateDialog, client=mocked_client)
limits_index = add_state_dialog._type_combo.findText(bl_states.DeviceWithinLimitsState.__name__)
assert limits_index >= 0
add_state_dialog._type_combo.setCurrentIndex(limits_index)
assert add_state_dialog._config_form.model is bl_states.DeviceWithinLimitsState.CONFIG_CLASS
name = add_state_dialog._config_form.input_widget("name")
device = add_state_dialog._config_form.input_widget("device")
signal = add_state_dialog._config_form.input_widget("signal")
low_limit = add_state_dialog._config_form.field_widget("low_limit")
high_limit = add_state_dialog._config_form.field_widget("high_limit")
name.setText("samx-limits")
WidgetIO.set_value(device, "samx")
WidgetIO.set_value(signal, "samx")
low_limit.checkbox.setChecked(True)
high_limit.checkbox.setChecked(True)
high_limit.value_widget.setValue(15.0)
config = add_state_dialog.config()
assert config.name == "samx_limits"
assert config.device == "samx"
assert config.signal == "samx"
assert config.low_limit == 0.0
assert config.high_limit == 15.0
def test_add_beamline_state_dialog_generates_name_only_after_valid_device_selection(
qtbot, mocked_client
):
add_state_dialog = create_widget(qtbot, AddBeamlineStateDialog, client=mocked_client)
name = add_state_dialog._config_form.input_widget("name")
device = add_state_dialog._config_form.input_widget("device")
device.setCurrentText("s")
assert name.text() == ""
device.set_device("samx")
assert name.text() == "samx_device_within_limits_state"
def test_add_beamline_state_dialog_switches_state_type_without_collapsing(qtbot, mocked_client):
add_state_dialog = create_widget(qtbot, AddBeamlineStateDialog, client=mocked_client)
initial_height = add_state_dialog.height()
limits_index = add_state_dialog._type_combo.findText("DeviceWithinLimitsState")
assert limits_index >= 0
shutter_index = add_state_dialog._type_combo.findText("ShutterState")
assert shutter_index >= 0
add_state_dialog._type_combo.setCurrentIndex(shutter_index)
qtbot.wait(0)
assert add_state_dialog._config_form.model is bl_states.DeviceStateConfig
assert add_state_dialog._config_form_host.count() == 1
assert not add_state_dialog._config_form.isHidden()
assert not add_state_dialog._buttons.isHidden()
assert add_state_dialog.sizeHint().height() > add_state_dialog._buttons.sizeHint().height()
assert add_state_dialog.minimumHeight() == add_state_dialog.maximumHeight()
add_state_dialog._type_combo.setCurrentIndex(limits_index)
qtbot.wait(0)
assert add_state_dialog._config_form.model is bl_states.DeviceWithinLimitsState.CONFIG_CLASS
assert add_state_dialog.height() >= initial_height
assert add_state_dialog.minimumHeight() == add_state_dialog.maximumHeight()
def test_add_beamline_state_dialog_cleanup_deletes_device_widgets(qtbot, mocked_client):
add_state_dialog = create_widget(qtbot, AddBeamlineStateDialog, client=mocked_client)
device = add_state_dialog._config_form.input_widget("device")
signal = add_state_dialog._config_form.input_widget("signal")
add_state_dialog.reject()
assert shiboken6.isValid(device)
assert shiboken6.isValid(signal)
add_state_dialog.cleanup()
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
assert not shiboken6.isValid(device)
assert not shiboken6.isValid(signal)
+176 -20
View File
@@ -1,5 +1,8 @@
import numpy as np
from unittest import mock
import pytest
from qtpy.QtGui import QPalette
from qtpy.QtWidgets import QProgressBar
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import (
BECProgressBar,
@@ -15,6 +18,14 @@ def progressbar(qtbot):
yield widget
@pytest.fixture
def static_progressbar(qtbot):
widget = BECProgressBar(enable_dynamic_stylesheet=False)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_progressbar(progressbar):
progressbar.update()
@@ -23,36 +34,181 @@ def test_progressbar_set_value(qtbot, progressbar):
progressbar.set_minimum(0)
progressbar.set_maximum(100)
progressbar.set_value(50)
progressbar.paintEvent(None)
qtbot.waitUntil(
lambda: np.isclose(
progressbar._value, progressbar._user_value * progressbar._oversampling_factor
)
)
assert isinstance(progressbar.progressbar, QProgressBar)
assert progressbar._value == progressbar._user_value * progressbar._oversampling_factor
assert progressbar.progressbar.value() == 50 * progressbar._oversampling_factor
def test_progressbar_label(progressbar):
progressbar.label_template = "Test: $value"
progressbar.set_value(50)
assert progressbar.center_label.text() == "Test: 50"
assert progressbar._get_label() == "Test: 50"
assert progressbar.progressbar.text() == "Test: 50"
def test_progress_state_from_bec_status():
"""ProgressState.from_bec_status() maps BEC literals correctly."""
mapping = {
"open": ProgressState.NORMAL,
"paused": ProgressState.PAUSED,
"aborted": ProgressState.INTERRUPTED,
"halted": ProgressState.PAUSED,
"closed": ProgressState.COMPLETED,
"UNKNOWN": ProgressState.NORMAL, # fallback
}
for text, expected in mapping.items():
assert ProgressState.from_bec_status(text) is expected
def test_progressbar_equal_minimum_and_maximum_does_not_raise(progressbar):
progressbar.set_minimum(0)
progressbar.set_maximum(0)
progressbar.set_value(0)
assert progressbar._get_label() == "0 / 0 - 100 %"
assert progressbar.progressbar.value() == progressbar.progressbar.maximum()
def test_progressbar_uses_static_stylesheet_with_palette_state_color(progressbar):
progressbar.progressbar.resize(100, 20)
progressbar.set_value(50)
progressbar.state = ProgressState.PAUSED
style_sheet = progressbar.progressbar.styleSheet()
assert "QProgressBar::chunk" in style_sheet
assert (
f"background-color: {progressbar._state_colors[ProgressState.PAUSED].name()};"
in style_sheet
)
assert "background-color: palette(mid);" in style_sheet
assert "border-radius: 7px;" in style_sheet
assert (
progressbar.progressbar.palette().color(QPalette.ColorRole.Highlight)
== progressbar._state_colors[ProgressState.PAUSED]
)
def test_progressbar_value_updates_do_not_rebuild_stylesheet_within_same_chunk_mode(progressbar):
progressbar.progressbar.resize(100, 20)
progressbar.set_value(30)
with mock.patch.object(
progressbar, "_setup_style_sheet", wraps=progressbar._setup_style_sheet
) as setup_style_sheet:
progressbar.set_value(35)
progressbar.set_value(42)
progressbar.set_value(50)
setup_style_sheet.assert_not_called()
def test_progressbar_value_updates_skip_chunk_radius_after_target_reached(progressbar):
progressbar.progressbar.resize(100, 20)
progressbar.set_value(30)
assert progressbar._chunk_radius == progressbar._target_chunk_radius()
with mock.patch.object(
progressbar, "_update_chunk_radius", wraps=progressbar._update_chunk_radius
) as update_chunk_radius:
progressbar.set_value(35)
progressbar.set_value(42)
progressbar.set_value(50)
update_chunk_radius.assert_not_called()
def test_progressbar_repeated_same_maximum_does_not_reset_chunk_radius(progressbar):
progressbar.progressbar.resize(100, 20)
progressbar.set_maximum(100)
progressbar.set_value(30)
assert progressbar._chunk_radius == progressbar._target_chunk_radius()
with mock.patch.object(
progressbar, "_update_chunk_radius", wraps=progressbar._update_chunk_radius
) as update_chunk_radius:
progressbar.set_maximum(100)
progressbar.set_value(40)
update_chunk_radius.assert_not_called()
def test_progressbar_can_disable_dynamic_stylesheet(static_progressbar):
static_progressbar.progressbar.resize(100, 20)
assert static_progressbar.enable_dynamic_stylesheet is False
assert static_progressbar._chunk_radius == static_progressbar._target_chunk_radius()
with mock.patch.object(
static_progressbar, "_setup_style_sheet", wraps=static_progressbar._setup_style_sheet
) as setup_style_sheet:
static_progressbar.set_value(1)
static_progressbar.set_value(2)
static_progressbar.set_value(3)
setup_style_sheet.assert_not_called()
assert "border-radius: 7px;" in static_progressbar.progressbar.styleSheet()
def test_progressbar_dynamic_stylesheet_can_be_toggled(progressbar):
progressbar.enable_dynamic_stylesheet = False
assert progressbar.enable_dynamic_stylesheet is False
assert progressbar._chunk_radius == progressbar._target_chunk_radius()
assert "border-radius: 7px;" in progressbar.progressbar.styleSheet()
def test_progressbar_rebuilds_stylesheet_until_chunk_radius_reaches_target(progressbar):
progressbar.progressbar.resize(100, 20)
progressbar.set_value(9)
with mock.patch.object(
progressbar, "_setup_style_sheet", wraps=progressbar._setup_style_sheet
) as setup_style_sheet:
progressbar.set_value(12)
progressbar.set_value(25)
progressbar.set_value(30)
assert setup_style_sheet.call_count == 2
assert "border-radius: 7px;" in progressbar.progressbar.styleSheet()
def test_progressbar_resets_chunk_radius_when_value_goes_backwards(progressbar):
progressbar.progressbar.resize(100, 20)
progressbar.set_value(30)
assert "border-radius: 7px;" in progressbar.progressbar.styleSheet()
progressbar.set_value(4)
assert "border-radius: 2px;" in progressbar.progressbar.styleSheet()
def test_progressbar_state_setter(progressbar):
"""Setting .state reflects internally."""
progressbar.state = ProgressState.PAUSED
assert progressbar.state is ProgressState.PAUSED
def test_progressbar_warning_state_has_own_color_and_persists_on_value_update(progressbar):
assert (
progressbar._state_colors[ProgressState.PAUSED]
!= progressbar._state_colors[ProgressState.WARNING]
)
assert (
progressbar._state_colors[ProgressState.WARNING]
!= progressbar._state_colors[ProgressState.INTERRUPTED]
)
progressbar.state = ProgressState.WARNING
progressbar.set_value(50)
assert progressbar.state is ProgressState.WARNING
assert (
progressbar.progressbar.palette().color(QPalette.ColorRole.Highlight)
== progressbar._state_colors[ProgressState.WARNING]
)
def test_progressbar_warning_state_has_own_color_and_persists_on_value_update(progressbar):
assert (
progressbar._state_colors[ProgressState.PAUSED]
!= progressbar._state_colors[ProgressState.WARNING]
)
assert (
progressbar._state_colors[ProgressState.WARNING]
!= progressbar._state_colors[ProgressState.INTERRUPTED]
)
progressbar.state = ProgressState.WARNING
progressbar.set_value(50)
assert progressbar.state is ProgressState.WARNING
assert (
progressbar.progressbar.palette().color(QPalette.ColorRole.Highlight)
== progressbar._state_colors[ProgressState.WARNING]
)
+22 -6
View File
@@ -2,11 +2,11 @@ import pyqtgraph as pg
import pytest
from pydantic import ValidationError
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QVBoxLayout, QWidget
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors, apply_theme
from bec_widgets.utils.colors import Colors, apply_theme, get_theme_name, rgba, theme_color
from bec_widgets.widgets.plots.waveform.curve import CurveConfig
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
@@ -76,10 +76,26 @@ def test_hex_to_rgba():
Colors.hex_to_rgba("#FF573")
def test_rgba_to_hex():
assert Colors.rgba_to_hex(255, 87, 51, 255) == "#FF5733FF"
assert Colors.rgba_to_hex(255, 87, 51, 128) == "#FF573380"
assert Colors.rgba_to_hex(255, 87, 51) == "#FF5733FF"
def test_get_theme_name_uses_application_theme():
app = QApplication.instance()
assert app.theme.theme == "light"
assert get_theme_name() == "light"
def test_theme_color_uses_theme_color_method():
app = QApplication.instance()
fallback = QColor("#ffffff")
expected = app.theme.color("FG", fallback.name())
assert theme_color(app.theme, "FG", fallback).name() == expected.name()
def test_theme_color_returns_fallback_without_theme():
assert theme_color(None, "FG", QColor("#ffffff")).name() == "#ffffff"
def test_qss_rgba_helper():
assert rgba(QColor("#010203"), 255) == "rgba(1, 2, 3, 255)"
def test_canonical_colormap_name_case_insensitive():
@@ -40,7 +40,7 @@ def test_update_device_initialization_progress(progress_bar, qtbot):
assert progress_bar.progress_bar._user_value == 1
assert progress_bar.progress_bar._user_maximum == 3
assert progress_bar.progress_label.text() == f"{msg.device} initialization in progress..."
assert "1 / 3 - 33 %" == progress_bar.progress_bar.center_label.text()
assert "1 / 3 - 33 %" == progress_bar.progress_bar.progressbar.text()
# II. Update with message of finished DeviceInitializationProgressMessage, finished=True, success=True
msg.finished = True
@@ -49,7 +49,7 @@ def test_update_device_initialization_progress(progress_bar, qtbot):
assert progress_bar.progress_bar._user_value == 1
assert progress_bar.progress_bar._user_maximum == 3
assert progress_bar.progress_label.text() == f"{msg.device} initialization succeeded!"
assert "1 / 3 - 33 %" == progress_bar.progress_bar.center_label.text()
assert "1 / 3 - 33 %" == progress_bar.progress_bar.progressbar.text()
# III. Update with message of finished DeviceInitializationProgressMessage, finished=True, success=False
msg.finished = True
@@ -59,7 +59,7 @@ def test_update_device_initialization_progress(progress_bar, qtbot):
with qtbot.waitSignal(progress_bar.failed_devices_changed) as signal_blocker:
progress_bar._update_device_initialization_progress(msg.model_dump(), {})
assert progress_bar.progress_label.text() == f"{msg.device} initialization failed!"
assert "2 / 3 - 66 %" == progress_bar.progress_bar.center_label.text()
assert "2 / 3 - 66 %" == progress_bar.progress_bar.progressbar.text()
assert progress_bar.progress_bar._user_value == 2
assert progress_bar.progress_bar._user_maximum == 3
@@ -120,7 +120,7 @@ def test_device_input_combobox_disabled_invalid_has_neutral_border(device_input_
assert "red" in device_input_combobox.styleSheet()
device_input_combobox.setEnabled(False)
assert "transparent" in device_input_combobox.styleSheet()
assert device_input_combobox.styleSheet() == ""
device_input_combobox.setEnabled(True)
assert "red" in device_input_combobox.styleSheet()
+17 -1
View File
@@ -96,6 +96,22 @@ def test_signal_combobox_autocomplete(qtbot, mocked_client):
assert text_changes[-1] == "manual_signal"
def test_signal_combobox_group_headers_are_disabled(qtbot, mocked_client):
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
widget.set_device("samx")
assert widget.itemText(0) == "Hinted Signals"
assert widget.itemText(2) == "Normal Signals"
assert widget.itemText(4) == "Config Signals"
assert widget.model().item(0).isEnabled() is False
assert widget.model().item(2).isEnabled() is False
assert widget.model().item(4).isEnabled() is False
assert widget.set_to_first_enabled() is True
assert widget.currentText() == "samx (readback)"
def test_signal_combobox_qproperties(device_signal_combobox):
device_signal_combobox.include_config_signals = False
device_signal_combobox.include_normal_signals = False
@@ -116,7 +132,7 @@ def test_signal_combobox_disabled_invalid_has_neutral_border(device_signal_combo
assert "red" in device_signal_combobox.styleSheet()
device_signal_combobox.setEnabled(False)
assert "transparent" in device_signal_combobox.styleSheet()
assert device_signal_combobox.styleSheet() == ""
device_signal_combobox.setEnabled(True)
assert "red" in device_signal_combobox.styleSheet()
+10 -1
View File
@@ -869,7 +869,14 @@ class TestToolbarFunctionality:
def test_toolbar_utils_actions(self, advanced_dock_area):
"""Test utils toolbar actions trigger widget creation."""
utils_actions = ["queue", "terminal", "status", "progress_bar", "sbb_monitor"]
utils_actions = [
"queue",
"terminal",
"status",
"progress_bar",
"sbb_monitor",
"beamline_state_manager",
]
for action_name in utils_actions:
with patch.object(advanced_dock_area, "new") as mock_new:
@@ -2428,6 +2435,7 @@ class TestFlatToolbarActions:
"flat_terminal",
"flat_bec_shell",
"flat_sbb_monitor",
"flat_beamline_state_manager",
]
for action_name in utils_actions:
@@ -2472,6 +2480,7 @@ class TestFlatToolbarActions:
"flat_terminal": "BecConsole",
"flat_bec_shell": "BECShell",
"flat_sbb_monitor": "SBBMonitor",
"flat_beamline_state_manager": "BeamlineStateManager",
}
for action_name, widget_type in utils_action_mapping.items():
@@ -1,10 +1,24 @@
from decimal import Decimal
from unittest.mock import patch
import pytest
from bec_lib.device import Device, Signal
from bec_lib.scan_args import ScanArgument
from pydantic import BaseModel, Field
from qtpy.QtWidgets import QCheckBox, QLabel, QLineEdit
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, TypedForm
from bec_widgets.utils.forms_from_types.items import FloatDecimalFormItem, IntFormItem, StrFormItem
from bec_widgets.utils.forms_from_types.pydantic_widget_form import (
OptionalValueWidget,
PydanticWidgetForm,
)
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
from .client_mocks import mocked_client
# pylint: disable=no-member
# pylint: disable=missing-function-docstring
@@ -26,6 +40,80 @@ class ExampleSchema(BaseModel):
decimal_dp_limits_nodefault: Decimal = Field(decimal_places=2, gt=1, le=34.5)
class GeneratedBeamlineSchema(BaseModel):
name: str = Field(title="State name", description="Unique state identifier.")
title: str | None = Field(default=None, title="Display title", description="Visible title.")
device: Device | str = Field(title="Device", description="BEC device.")
signal: Signal | str | None = Field(
default=None, title="Signal", description="Optional device signal."
)
limit: float | None = Field(
default=None,
title="Limit",
description="Optional numeric limit.",
json_schema_extra={"precision": 6},
)
tolerance: float = Field(
default=0.1,
title="Tolerance",
description="Warning tolerance.",
json_schema_extra={"precision": 6},
)
model_config = {"arbitrary_types_allowed": True}
class GeneratedPlainSchema(BaseModel):
sample_name: str
class GeneratedDeviceOnlySchema(BaseModel):
device: Device | str = Field(default="", title="Device")
model_config = {"arbitrary_types_allowed": True}
class GeneratedSignalOnlySchema(BaseModel):
signal: Signal | str | None = Field(default=None, title="Signal")
model_config = {"arbitrary_types_allowed": True}
class GeneratedScanArgumentSchema(BaseModel):
device: Device | str = Field(
default="", **ScanArgument(display_name="Device", description="Device source.").model_dump()
)
signal: Signal | str | None = Field(
default=None,
**ScanArgument(display_name="Signal", description="Signal source.").model_dump(),
)
low_limit: float | None = Field(
default=None,
**ScanArgument(
display_name="Low limit",
description="Optional lower bound.",
reference_units="device",
precision=4,
ge=-5,
le=5,
).model_dump(),
)
exposure: float = Field(
default=0.1,
**ScanArgument(
display_name="Exposure", tooltip="Camera exposure.", units="s", precision=3, gt=0
).model_dump(),
)
model_config = {"arbitrary_types_allowed": True}
class GeneratedRequiredNumericAndOptionalBoolSchema(BaseModel):
enabled: bool | None = None
retry_count: int
scale: float
TEST_DICT = {
"sample_name": "test name",
"str_optional": "None",
@@ -74,3 +162,168 @@ def test_widget_set_data(model_widget: PydanticModelForm):
"decimal_dp_limits_nodefault",
]:
assert model_widget.widget_dict[key].getValue() == TEST_DICT[key]
def test_pydantic_widget_form_uses_field_metadata_and_type_widgets(qtbot, mocked_client):
form = PydanticWidgetForm(GeneratedBeamlineSchema, client=mocked_client)
qtbot.addWidget(form)
assert isinstance(form.input_widget("name"), QLineEdit)
assert isinstance(form.input_widget("device"), DeviceComboBox)
assert isinstance(form.input_widget("signal"), SignalComboBox)
assert isinstance(form.field_widget("limit"), OptionalValueWidget)
assert isinstance(form.input_widget("limit"), BECSpinBox)
assert form.input_widgets_by_type(DeviceComboBox) == [form.input_widget("device")]
assert form.input_widgets_by_type(SignalComboBox) == [form.input_widget("signal")]
label = form.layout().labelForField(form.field_widget("device"))
assert isinstance(label, QLabel)
assert label.text() == "Device"
assert label.toolTip() == "BEC device."
assert form.field_widget("limit").toolTip() == "Optional numeric limit."
def test_pydantic_widget_form_device_signal_variants(qtbot, mocked_client):
device_signal_form = PydanticWidgetForm(GeneratedBeamlineSchema, client=mocked_client)
device_only_form = PydanticWidgetForm(GeneratedDeviceOnlySchema, client=mocked_client)
signal_only_form = PydanticWidgetForm(GeneratedSignalOnlySchema, client=mocked_client)
qtbot.addWidget(device_signal_form)
qtbot.addWidget(device_only_form)
qtbot.addWidget(signal_only_form)
assert isinstance(device_signal_form.input_widget("device"), DeviceComboBox)
assert isinstance(device_signal_form.input_widget("signal"), SignalComboBox)
assert device_signal_form.input_widget("signal").require_device is True
assert isinstance(device_only_form.input_widget("device"), DeviceComboBox)
assert device_only_form.input_widgets_by_type(SignalComboBox) == []
assert isinstance(signal_only_form.input_widget("signal"), SignalComboBox)
assert signal_only_form.input_widget("signal").require_device is False
assert signal_only_form.input_widgets_by_type(DeviceComboBox) == []
def test_pydantic_widget_form_plain_field_has_generated_label_and_no_tooltip(qtbot):
form = PydanticWidgetForm(GeneratedPlainSchema)
qtbot.addWidget(form)
label = form.layout().labelForField(form.field_widget("sample_name"))
assert isinstance(label, QLabel)
assert label.text() == "Sample name"
assert label.toolTip() == ""
assert form.field_widget("sample_name").toolTip() == ""
def test_pydantic_model_input_configs_reads_bl_states_annotated_scan_arguments():
"""Contract test: ScanArgument metadata attached via ``Annotated`` in bec_lib's beamline
state configs must reach the generated-form configuration."""
from bec_lib import bl_states
from bec_widgets.utils.forms_from_types.pydantic_model_info_adapter import (
pydantic_model_input_configs,
)
items = {
item["name"]: item
for item in pydantic_model_input_configs(bl_states.DeviceWithinLimitsState.CONFIG_CLASS)
}
assert items["name"]["display_name"] == "State name"
assert items["name"]["tooltip"]
assert items["device"]["display_name"] == "Device"
assert items["low_limit"]["reference_units"] == "device"
assert items["high_limit"]["reference_units"] == "device"
assert items["tolerance"]["reference_units"] == "device"
def test_pydantic_widget_form_uses_scan_argument_metadata(qtbot, mocked_client):
form = PydanticWidgetForm(GeneratedScanArgumentSchema, client=mocked_client)
qtbot.addWidget(form)
low_limit = form.field_widget("low_limit")
low_limit_input = form.input_widget("low_limit")
exposure = form.input_widget("exposure")
low_limit_label = form.layout().labelForField(low_limit)
assert isinstance(low_limit_label, QLabel)
assert low_limit_label.text() == "Low limit"
assert low_limit.toolTip() == "Optional lower bound.\nUnits from: device"
assert low_limit_input.toolTip() == "Optional lower bound.\nUnits from: device"
assert low_limit_input.decimals() == 4
assert low_limit_input.minimum() == pytest.approx(-5)
assert low_limit_input.maximum() == pytest.approx(5)
assert form.field_widget("exposure").toolTip() == "Camera exposure.\nUnits: s"
assert exposure.toolTip() == "Camera exposure.\nUnits: s"
assert exposure.suffix() == " s"
assert exposure.decimals() == 3
assert exposure.minimum() == pytest.approx(0.001)
with patch.object(mocked_client.device_manager.devices.samx, "egu", return_value="mm"):
WidgetIO.set_value(form.input_widget("device"), "samx")
assert low_limit.toolTip() == "Optional lower bound.\nUnits: mm"
assert low_limit_input.toolTip() == "Optional lower bound.\nUnits: mm"
assert low_limit_input.suffix() == " mm"
def test_pydantic_widget_form_cleans_up_on_close(qtbot):
form = PydanticWidgetForm(GeneratedPlainSchema)
qtbot.addWidget(form)
form.close()
assert form.widgets == {}
assert form.layout().count() == 0
def test_pydantic_widget_form_round_trips_optional_numeric_and_dirty_state(qtbot, mocked_client):
form = PydanticWidgetForm(
GeneratedBeamlineSchema,
client=mocked_client,
data={"name": "state_1", "title": "State", "device": "samx", "signal": "samx"},
)
qtbot.addWidget(form)
assert form.get_data()["limit"] is None
limit = form.field_widget("limit")
limit.checkbox.setChecked(True)
form.input_widget("limit").setValue(5.0)
assert form.get_data()["limit"] == 5.0
assert form.model_instance().limit == 5.0
assert "limit" in form.dirty_fields()
form.reset_to_baseline()
assert form.get_data()["limit"] is None
assert form.dirty_fields() == set()
def test_pydantic_widget_form_initializes_required_numeric_fields(qtbot):
form = PydanticWidgetForm(GeneratedRequiredNumericAndOptionalBoolSchema)
qtbot.addWidget(form)
assert form.raw_data()["retry_count"] == 0
assert form.raw_data()["scale"] == 0.0
assert form.model_instance().retry_count == 0
assert form.model_instance().scale == 0.0
def test_pydantic_widget_form_preserves_optional_bool_none(qtbot):
form = PydanticWidgetForm(GeneratedRequiredNumericAndOptionalBoolSchema)
qtbot.addWidget(form)
enabled = form.field_widget("enabled")
assert isinstance(enabled, OptionalValueWidget)
assert isinstance(form.input_widget("enabled"), QCheckBox)
assert form.raw_data()["enabled"] is None
assert form.model_instance().enabled is None
enabled.checkbox.setChecked(True)
form.input_widget("enabled").setChecked(True)
assert form.raw_data()["enabled"] is True
assert form.model_instance().enabled is True
-5
View File
@@ -69,11 +69,6 @@ def test_display_app_id_connected(bec_main_window):
assert bec_main_window._app_id_label.text() == "App ID: gui_123"
def test_event_consumes_status_tip(bec_main_window):
status_tip_event = QEvent(QEvent.Type.StatusTip)
assert bec_main_window.event(status_tip_event) is True
def test_get_launcher_from_qapp_returns_none_when_absent(bec_main_window):
with patch.object(
QApplication, "instance", return_value=SimpleNamespace(topLevelWidgets=lambda: [])
+13
View File
@@ -0,0 +1,13 @@
from bec_widgets.utils.name_utils import pascal_to_snake, sanitize_namespace
def test_pascal_to_snake():
assert pascal_to_snake("DeviceWithinLimitsState") == "device_within_limits_state"
assert pascal_to_snake("BECStatusWidget") == "bec_status_widget"
def test_sanitize_namespace():
assert sanitize_namespace("scan 1 / user") == "scan_1_user"
assert sanitize_namespace(" beamline.state-1 ") == "beamline.state-1"
assert sanitize_namespace(" ") is None
assert sanitize_namespace(None) is None
+26
View File
@@ -40,11 +40,23 @@ def test_apply_theme_updates_colours(qtbot, toast):
"""apply_theme("light") should inject LIGHT palette colours into stylesheets."""
toast.apply_theme("light")
assert LIGHT_PALETTE["title"] in toast._title_lbl.styleSheet()
assert "border: 1px solid" in toast.trace_view.styleSheet()
assert "border:none" not in toast.trace_view.styleSheet()
toast.apply_theme("dark")
assert DARK_PALETTE["title"] in toast._title_lbl.styleSheet()
def test_toast_updates_from_qapp_theme_changed_signal(qtbot, toast):
app = QtWidgets.QApplication.instance()
assert hasattr(app, "theme")
app.theme.theme_changed.emit("light")
qtbot.wait(10)
assert LIGHT_PALETTE["title"] in toast._title_lbl.styleSheet()
def test_expired_signal(qtbot, toast):
"""Toast must emit expired once its lifetime finishes."""
with qtbot.waitSignal(toast.expired, timeout=1000):
@@ -251,6 +263,20 @@ def test_theme_propagation(qtbot, centre):
assert LIGHT_PALETTE["title"] in toast._title_lbl.styleSheet()
def test_centre_updates_from_qapp_theme_changed_signal(qtbot, centre):
toast = _post(centre, SeverityKind.INFO)
centre.apply_theme("dark")
app = QtWidgets.QApplication.instance()
assert hasattr(app, "theme")
app.theme.theme_changed.emit("light")
qtbot.wait(10)
assert centre._theme == "light"
assert LIGHT_PALETTE["title"] in toast._title_lbl.styleSheet()
# ------------------------------------------------------------------------
# NotificationIndicator tests
# ------------------------------------------------------------------------
+179
View File
@@ -0,0 +1,179 @@
from unittest import mock
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.widgets.progress.progress_backend import BECProgressTracker
def _dispatcher():
dispatcher = mock.MagicMock()
return dispatcher
def test_tracker_subscribes_to_scan_progress_immediately():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
tracker.start()
assert dispatcher.connect_slot.call_args_list == [
mock.call(tracker.process_progress_message, MessageEndpoints.scan_progress()),
mock.call(tracker.process_scan_status_message, MessageEndpoints.scan_status()),
]
tracker.cleanup()
def test_tracker_starts_scan_from_scan_progress_metadata():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_progress_message(
{"value": 3, "max_value": 10},
{"scan_id": "scan-2", "RID": "rid-2", "scan_number": 2, "status": "open"},
)
assert tracker.task is not None
assert tracker._active_scan_id == "scan-2"
assert tracker._active_rid == "rid-2"
assert tracker.scan_number == 2
assert snapshots[-1].scan_number == 2
tracker.cleanup()
def test_tracker_switches_sources_idempotently():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
tracker.start()
tracker.start()
assert dispatcher.connect_slot.call_count == 2
assert dispatcher.disconnect_slot.call_count == 0
tracker.cleanup()
def test_tracker_resets_progress_on_new_open_scan_status():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
snapshot = tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {}
)
assert snapshot is not None
assert snapshot.value == 0
assert snapshot.max_value == 100
assert snapshot.status == "open"
assert snapshot.scan_id == "scan-1"
assert snapshot.scan_number == 7
assert snapshot.is_new_scan is True
assert tracker.task is None
assert tracker.scan_number == 7
assert snapshots[-1] == snapshot
tracker.cleanup()
def test_tracker_ignores_duplicate_open_scan_status():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_scan_status_message({"scan_id": "scan-1", "status": "open"}, {})
tracker.process_scan_status_message({"scan_id": "scan-1", "status": "open"}, {})
assert len(snapshots) == 1
tracker.cleanup()
def test_tracker_ignores_open_scan_status_after_progress_for_same_scan():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_progress_message(
{"value": 3, "max_value": 10}, {"scan_id": "scan-1", "RID": "rid-1", "status": "open"}
)
snapshot = tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {}
)
assert snapshot is None
assert len(snapshots) == 1
assert snapshots[-1].value == 3
assert tracker.task is not None
assert tracker.task.value == 3
tracker.cleanup()
def test_tracker_ignores_open_scan_status_after_done_progress_for_same_scan():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_progress_message(
{"value": 10, "max_value": 10, "done": True},
{"scan_id": "scan-1", "RID": "rid-1", "status": "closed"},
)
snapshot = tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {}
)
assert snapshot is None
assert len(snapshots) == 1
assert snapshots[-1].value == 10
assert tracker.task is None
tracker.cleanup()
def test_tracker_marks_new_scan_only_when_rid_changes():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_progress_message({"value": 10, "max_value": 100}, {"RID": "rid-1"})
tracker.process_progress_message({"value": 20, "max_value": 200}, {"RID": "rid-1"})
tracker.process_progress_message({"value": 5, "max_value": 50}, {"RID": "rid-2"})
assert [snapshot.is_new_scan for snapshot in snapshots] == [True, False, True]
assert tracker._active_rid == "rid-2"
tracker.cleanup()
def test_tracker_keeps_partial_value_for_done_scan_progress():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_progress_message(
{"value": 4, "max_value": 10, "done": True},
{"scan_id": "scan-1", "RID": "rid-1", "status": "aborted"},
)
assert snapshots[-1].value == 4
assert snapshots[-1].max_value == 10
assert snapshots[-1].done is True
assert tracker.task is None
tracker.cleanup()
+146 -26
View File
@@ -1,8 +1,9 @@
# pylint: disable=missing-function-docstring, missing-module-docstring
from unittest.mock import MagicMock
from unittest.mock import MagicMock, call
import pytest
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtGui import QColor
from bec_widgets.tests.utils import FakeDevice
@@ -76,11 +77,14 @@ def test_set_update_to_scan(ring_widget):
ring_widget.set_update("scan")
assert ring_widget.config.mode == "scan"
# Verify that connect_slot was called
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.on_scan_progress
assert "scan_progress" in str(call_args[0][1])
assert ring_widget.bec_dispatcher.connect_slot.call_args_list == [
call(
ring_widget.progress_tracker.process_progress_message, MessageEndpoints.scan_progress()
),
call(
ring_widget.progress_tracker.process_scan_status_message, MessageEndpoints.scan_status()
),
]
def test_set_update_from_scan_to_manual(ring_widget):
@@ -97,6 +101,14 @@ def test_set_update_from_scan_to_manual(ring_widget):
assert ring_widget.config.mode == "manual"
assert ring_widget.registered_slot is None
assert ring_widget.bec_dispatcher.disconnect_slot.call_args_list == [
call(
ring_widget.progress_tracker.process_progress_message, MessageEndpoints.scan_progress()
),
call(
ring_widget.progress_tracker.process_scan_status_message, MessageEndpoints.scan_status()
),
]
def test_set_update_to_device(ring_widget_with_device):
@@ -420,7 +432,7 @@ def test_set_direction_counter_clockwise(ring_widget):
###################################
def test_update_device_connection_with_progress_signal(ring_widget_with_device):
def test_update_device_connection_prefers_progress_signal(ring_widget_with_device):
ring_widget = ring_widget_with_device
samx = ring_widget.bec_dispatcher.client.device_manager.devices.samx
samx._info["signals"]["progress"] = {
@@ -432,15 +444,114 @@ def test_update_device_connection_with_progress_signal(ring_widget_with_device):
ring_widget.bec_dispatcher.connect_slot = MagicMock()
ring_widget._update_device_connection("samx", "progress")
signal = ring_widget._update_device_connection("samx", "")
# Should connect to device_progress endpoint
assert signal == "progress"
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.on_device_progress
assert call_args[0][1] == MessageEndpoints.device_progress("samx")
def test_update_device_connection_with_hinted_signal(ring_widget):
def test_update_device_connection_accepts_explicit_progress_signal(ring_widget_with_device):
ring_widget = ring_widget_with_device
samx = ring_widget.bec_dispatcher.client.device_manager.devices.samx
samx._info["signals"]["progress"] = {
"obj_name": "samx_progress",
"component_name": "progress",
"signal_class": "ProgressSignal",
"kind_str": "hinted",
}
ring_widget.bec_dispatcher.connect_slot = MagicMock()
signal = ring_widget._update_device_connection("samx", "progress")
assert signal == "progress"
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.on_device_progress
assert call_args[0][1] == MessageEndpoints.device_progress("samx")
def test_update_device_connection_resolves_component_name_to_readback_signal(
ring_widget_with_device,
):
ring_widget = ring_widget_with_device
samx = ring_widget.bec_dispatcher.client.device_manager.devices.samx
samx._info["signals"]["setpoint"] = {
"obj_name": "samx_setpoint",
"component_name": "setpoint",
"signal_class": "Signal",
"kind_str": "normal",
}
ring_widget.bec_dispatcher.connect_slot = MagicMock()
signal = ring_widget._update_device_connection("samx", "setpoint")
assert signal == "setpoint"
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.on_device_readback
assert call_args[0][1] == MessageEndpoints.device_readback("samx")
def test_update_device_connection_falls_back_to_hinted_signal(ring_widget_with_device):
ring_widget = ring_widget_with_device
ring_widget.bec_dispatcher.connect_slot = MagicMock()
signal = ring_widget._update_device_connection("samx", "")
assert signal == "samx"
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.on_device_readback
assert call_args[0][1] == MessageEndpoints.device_readback("samx")
def test_update_device_connection_falls_back_to_normal_signal(ring_widget_with_device):
ring_widget = ring_widget_with_device
samx = ring_widget.bec_dispatcher.client.device_manager.devices.samx
samx._info["signals"] = {
"setpoint": {
"obj_name": "samx_setpoint",
"component_name": "setpoint",
"signal_class": "Signal",
"kind_str": "normal",
}
}
ring_widget.bec_dispatcher.connect_slot = MagicMock()
signal = ring_widget._update_device_connection("samx", "")
assert signal == "setpoint"
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.on_device_readback
assert call_args[0][1] == MessageEndpoints.device_readback("samx")
def test_update_device_connection_rejects_unusable_signal(ring_widget_with_device):
ring_widget = ring_widget_with_device
samx = ring_widget.bec_dispatcher.client.device_manager.devices.samx
samx._info["signals"]["async_signal"] = {
"obj_name": "samx_async",
"component_name": "async_signal",
"signal_class": "AsyncSignal",
"kind_str": "hinted",
}
ring_widget.bec_dispatcher.connect_slot = MagicMock()
with pytest.raises(ValueError, match="not usable for ring progress device mode"):
ring_widget._update_device_connection("samx", "samx_async")
ring_widget.bec_dispatcher.connect_slot.assert_not_called()
def test_update_device_connection_accepts_explicit_hinted_signal(ring_widget):
mock_device = FakeDevice(name="samx")
mock_device._info = {
"signals": {
@@ -452,12 +563,13 @@ def test_update_device_connection_with_hinted_signal(ring_widget):
ring_widget.bec_dispatcher.connect_slot = MagicMock()
ring_widget._update_device_connection("samx", "samx")
signal = ring_widget._update_device_connection("samx", "samx")
# Should connect to device_readback endpoint
assert signal == "samx"
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.on_device_readback
assert call_args[0][1] == MessageEndpoints.device_readback("samx")
def test_update_device_connection_no_device_manager(ring_widget):
@@ -472,44 +584,52 @@ def test_update_device_connection_device_not_found(ring_widget):
mock_device = FakeDevice(name="samx")
ring_widget.bec_dispatcher.client.device_manager.devices["samx"] = mock_device
# Should return without raising an error
ring_widget._update_device_connection("nonexistent", "signal")
assert ring_widget._update_device_connection("nonexistent", "signal") == ""
###################################
# on_scan_progress tests
# scan progress tests
###################################
def test_on_scan_progress_updates_value(ring_widget):
def test_scan_progress_updates_value(ring_widget):
msg = {"value": 42, "max_value": 100}
meta = {"RID": "test_rid_123"}
ring_widget.on_scan_progress(msg, meta)
ring_widget.progress_tracker.process_progress_message(msg, meta)
assert ring_widget.config.value == 42
def test_on_scan_progress_updates_min_max_on_new_rid(ring_widget):
def test_scan_status_open_resets_scan_progress_value(ring_widget):
ring_widget.set_min_max_values(0, 200)
ring_widget.set_value(80)
ring_widget.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {}
)
assert ring_widget.config.min_value == 0
assert ring_widget.config.max_value == 100
assert ring_widget.config.value == 0
def test_scan_progress_updates_min_max_on_new_rid(ring_widget):
msg = {"value": 50, "max_value": 200}
meta = {"RID": "new_rid"}
ring_widget.RID = "old_rid"
ring_widget.on_scan_progress(msg, meta)
ring_widget.progress_tracker.process_progress_message(msg, meta)
assert ring_widget.config.min_value == 0
assert ring_widget.config.max_value == 200
assert ring_widget.config.value == 50
def test_on_scan_progress_same_rid_no_min_max_update(ring_widget):
msg = {"value": 75, "max_value": 300}
def test_scan_progress_same_rid_no_min_max_update(ring_widget):
meta = {"RID": "same_rid"}
ring_widget.RID = "same_rid"
ring_widget.set_min_max_values(0, 100)
ring_widget.on_scan_progress(msg, meta)
ring_widget.progress_tracker.process_progress_message({"value": 10, "max_value": 100}, meta)
ring_widget.progress_tracker.process_progress_message({"value": 75, "max_value": 300}, meta)
# Max value should not be updated when RID is the same
assert ring_widget.config.max_value == 100
@@ -0,0 +1,85 @@
import pytest
from qtpy.QtWidgets import QDoubleSpinBox, QSpinBox
from bec_widgets.utils.scan_arg_metadata import (
apply_numeric_limits,
apply_numeric_precision,
apply_unit_metadata,
device_units,
ui_config_from_metadata,
unit_tooltip,
)
from .conftest import create_widget
def test_unit_tooltip_and_cleanup(qtbot):
widget = create_widget(qtbot, QDoubleSpinBox)
item = {"tooltip": "Move start", "reference_units": "device"}
assert unit_tooltip(item) == "Move start\nUnits from: device"
apply_unit_metadata(widget, item)
assert widget.toolTip() == "Move start\nUnits from: device"
assert widget.suffix() == ""
apply_unit_metadata(widget, item, "mm")
assert widget.toolTip() == "Move start\nUnits: mm"
assert widget.suffix() == " mm"
apply_unit_metadata(widget, item, "deg")
assert widget.toolTip() == "Move start\nUnits: deg"
assert widget.suffix() == " deg"
def test_numeric_precision_and_limits(qtbot):
float_widget = create_widget(qtbot, QDoubleSpinBox)
int_widget = create_widget(qtbot, QSpinBox)
apply_numeric_precision(float_widget, {"name": "position", "precision": 3})
apply_numeric_limits(float_widget, {"ge": -1.5, "lt": 2.0})
apply_numeric_limits(int_widget, {"gt": 2, "le": 8})
assert float_widget.decimals() == 3
assert float_widget.minimum() == pytest.approx(-1.5)
assert float_widget.maximum() == pytest.approx(1.999)
assert int_widget.minimum() == 3
assert int_widget.maximum() == 8
def test_device_units_uses_egu():
class Device:
def egu(self):
return "mm"
assert device_units(Device()) == "mm"
assert device_units(object()) is None
def test_ui_config_from_metadata_matches_scan_control_item_shape():
item = ui_config_from_metadata(
name="exp_time",
input_type="float",
default=0.1,
metadata={"tooltip": "Exposure", "units": "s", "precision": 3, "ge": 0},
)
assert item == {
"arg": False,
"name": "exp_time",
"type": "float",
"display_name": "Exp Time",
"tooltip": "Exposure",
"default": 0.1,
"expert": False,
"hidden": False,
"precision": 3,
"units": "s",
"reference_units": None,
"reference_limits": None,
"gt": None,
"ge": 0,
"lt": None,
"le": None,
"alternative_group": None,
}
+138 -266
View File
@@ -2,7 +2,6 @@ from unittest import mock
import numpy as np
import pytest
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.utils.bec_widget import BECWidget
@@ -10,11 +9,8 @@ from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import (
BECProgressBar,
ProgressState,
)
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import (
ProgressSource,
ProgressTask,
ScanProgressBar,
)
from bec_widgets.widgets.progress.progress_backend import ProgressTask
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
from .client_mocks import mocked_client
@@ -27,30 +23,6 @@ def scan_progressbar(qtbot, mocked_client):
yield widget
@pytest.fixture
def scan_message():
return messages.ScanQueueMessage(
metadata={
"file_suffix": None,
"file_directory": None,
"user_metadata": {"sample_name": ""},
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
},
scan_type="line_scan",
parameter={
"args": {"samx": [-10.0, 10.0]},
"kwargs": {
"steps": 20,
"relative": False,
"exp_time": 0.1,
"burst_at_each_point": 1,
"system_config": {"file_suffix": None, "file_directory": None},
},
},
queue="primary",
)
def test_progress_task_basic():
"""percentage, remaining, and formatted time helpers behave as expected."""
task = ProgressTask(parent=None, value=50, max_value=100, done=False)
@@ -71,18 +43,52 @@ def test_progress_task_basic():
assert task.time_elapsed == "00:00:10"
def test_progress_task_elapsed_time_uses_monotonic_clock(monkeypatch):
times = iter([100.0, 102.5])
monkeypatch.setattr(
"bec_widgets.widgets.progress.progress_backend.time.monotonic", lambda: next(times)
)
task = ProgressTask(parent=None)
task.timer.stop()
task.update_elapsed_time()
assert task._elapsed_time == 2.5
assert task.time_elapsed == "00:00:02"
def test_scan_progressbar_initialization(scan_progressbar):
assert isinstance(scan_progressbar, ScanProgressBar)
assert isinstance(scan_progressbar.progressbar, BECProgressBar)
def test_scan_progressbar_passes_dynamic_stylesheet_setting(qtbot, mocked_client):
widget = ScanProgressBar(client=mocked_client, enable_dynamic_stylesheet=False)
qtbot.addWidget(widget)
assert widget.progressbar.enable_dynamic_stylesheet is False
def test_scan_progressbar_starts_from_scan_progress_before_queue_update(scan_progressbar):
scan_progressbar.progress_tracker.clear_task(emit_finished=False)
scan_progressbar.progress_tracker.process_progress_message(
{"value": 3, "max_value": 10, "done": False}, metadata={"RID": "live-rid"}
)
assert scan_progressbar.progress_tracker.task is not None
assert scan_progressbar.progress_tracker._active_scan_id == "live-rid"
assert scan_progressbar.progressbar._user_value == 3
assert scan_progressbar.progressbar._user_maximum == 10
def test_update_labels_content(scan_progressbar):
"""update_labels() reflects ProgressTask time strings on the UI."""
# fabricate a task with known timings
task = ProgressTask(parent=scan_progressbar, value=30, max_value=100, done=False)
task.timer.stop()
task._elapsed_time = 50
scan_progressbar.task = task
scan_progressbar.progress_tracker.task = task
scan_progressbar.update_labels()
@@ -90,17 +96,17 @@ def test_update_labels_content(scan_progressbar):
assert scan_progressbar.ui.remaining_time_label.text() == "00:01:57"
def test_on_progress_update(qtbot, scan_progressbar):
def test_progress_update(qtbot, scan_progressbar):
"""
on_progress_update() should forward new values to the embedded
BECProgressBar and keep ProgressTask in sync.
Scan progress updates should update the embedded BECProgressBar
and keep ProgressTask in sync.
"""
task = ProgressTask(parent=scan_progressbar, value=0, max_value=100, done=False)
task.timer.stop()
scan_progressbar.task = task
scan_progressbar.progress_tracker.task = task
msg = {"value": 20, "max_value": 100, "done": False}
scan_progressbar.on_progress_update(msg, metadata={"status": "open"})
scan_progressbar.progress_tracker.process_progress_message(msg, metadata={"status": "open"})
qtbot.wait(200)
bar = scan_progressbar.progressbar
@@ -110,14 +116,58 @@ def test_on_progress_update(qtbot, scan_progressbar):
assert bar.state is ProgressState.NORMAL
def test_scan_status_open_resets_progress_before_first_progress_update(scan_progressbar):
scan_progressbar.progressbar.set_maximum(50)
scan_progressbar.progressbar.set_value(25)
scan_progressbar.progressbar.state = ProgressState.INTERRUPTED
scan_progressbar.ui.elapsed_time_label.setText("00:00:12")
scan_progressbar.ui.remaining_time_label.setText("00:00:34")
scan_progressbar.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {"RID": "rid-1"}
)
assert scan_progressbar.progressbar._user_value == 0
assert scan_progressbar.progressbar._user_maximum == 100
assert scan_progressbar.progressbar.state is ProgressState.NORMAL
assert scan_progressbar.ui.elapsed_time_label.text() == "00:00:00"
assert scan_progressbar.ui.remaining_time_label.text() == "00:00:00"
assert scan_progressbar.ui.source_label.text() == "Scan 7"
def test_scan_status_open_reset_ignores_same_scan(scan_progressbar):
scan_progressbar.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "status": "open"}, {}
)
scan_progressbar.progressbar.set_value(20)
scan_progressbar.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "status": "open"}, {}
)
assert scan_progressbar.progressbar._user_value == 20
def test_scan_status_non_open_does_not_reset_progress(scan_progressbar):
scan_progressbar.progressbar.set_value(25)
scan_progressbar.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "status": "closed"}, {}
)
assert scan_progressbar.progressbar._user_value == 25
@pytest.mark.parametrize(
"status, value, max_val, expected_state",
[
("open", 10, 100, ProgressState.NORMAL),
("paused", 25, 100, ProgressState.PAUSED),
("aborted", 30, 100, ProgressState.INTERRUPTED),
("halted", 40, 100, ProgressState.PAUSED),
("aborted", 30, 100, ProgressState.WARNING),
("halted", 40, 100, ProgressState.INTERRUPTED),
("closed", 100, 100, ProgressState.COMPLETED),
("user_completed", 40, 100, ProgressState.COMPLETED),
("UNKNOWN", 10, 100, ProgressState.NORMAL),
],
)
def test_state_mapping_during_updates(
@@ -126,9 +176,9 @@ def test_state_mapping_during_updates(
"""ScanProgressBar should translate BEC status → ProgressState consistently."""
task = ProgressTask(parent=scan_progressbar, value=0, max_value=max_val, done=False)
task.timer.stop()
scan_progressbar.task = task
scan_progressbar.progress_tracker.task = task
scan_progressbar.on_progress_update(
scan_progressbar.progress_tracker.process_progress_message(
{"value": value, "max_value": max_val, "done": status == "closed"},
metadata={"status": status},
)
@@ -136,97 +186,39 @@ def test_state_mapping_during_updates(
assert scan_progressbar.progressbar.state is expected_state
def test_source_label_updates(scan_progressbar):
"""update_source_label() renders correct text for both progress sources."""
# device progress
scan_progressbar.update_source_label(ProgressSource.DEVICE_PROGRESS, device="motor")
assert scan_progressbar.ui.source_label.text() == "Device motor"
def test_aborted_done_scan_keeps_partial_progress(scan_progressbar):
scan_progressbar.progress_tracker.process_progress_message(
{"value": 4, "max_value": 10, "done": True},
metadata={"scan_id": "scan-1", "RID": "rid-1", "status": "aborted"},
)
# scan progress (needs a scan_number for deterministic text)
scan_progressbar.scan_number = 5
scan_progressbar.update_source_label(ProgressSource.SCAN_PROGRESS)
assert scan_progressbar.progressbar._user_value == 4
assert scan_progressbar.progressbar._user_maximum == 10
assert scan_progressbar.progressbar.state is ProgressState.WARNING
assert scan_progressbar.progress_tracker.task is None
def test_source_label_updates(scan_progressbar):
"""update_source_label() renders the current scan label."""
scan_progressbar.progress_tracker.scan_number = 5
scan_progressbar.update_source_label()
assert scan_progressbar.ui.source_label.text() == "Scan 5"
def test_set_progress_source_connections(scan_progressbar, monkeypatch):
""" """
def test_source_label_update_logs_only_on_text_change(scan_progressbar):
scan_progressbar.progress_tracker.scan_number = 5
connect_calls = []
disconnect_calls = []
with mock.patch(
"bec_widgets.widgets.progress.scan_progressbar.scan_progressbar.logger.info"
) as mock_info:
scan_progressbar.update_source_label()
scan_progressbar.update_source_label()
scan_progressbar.update_source_label()
def fake_connect(slot, endpoint):
connect_calls.append(endpoint)
def fake_disconnect(slot, endpoint):
disconnect_calls.append(endpoint)
# Patch dispatcher methods
monkeypatch.setattr(scan_progressbar.bec_dispatcher, "connect_slot", fake_connect)
monkeypatch.setattr(scan_progressbar.bec_dispatcher, "disconnect_slot", fake_disconnect)
# switch to SCAN_PROGRESS
scan_progressbar.scan_number = 7
scan_progressbar.set_progress_source(ProgressSource.SCAN_PROGRESS)
assert scan_progressbar._progress_source == ProgressSource.SCAN_PROGRESS
assert scan_progressbar.ui.source_label.text() == "Scan 7"
assert connect_calls[-1] == MessageEndpoints.scan_progress()
assert disconnect_calls == []
# switch to DEVICE_PROGRESS
device = "motor"
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
assert scan_progressbar._progress_source == ProgressSource.DEVICE_PROGRESS
assert scan_progressbar.ui.source_label.text() == f"Device {device}"
assert connect_calls[-1] == MessageEndpoints.device_progress(device=device)
assert disconnect_calls == [MessageEndpoints.scan_progress()]
# calling again with the SAME source should not add new connect calls
prev_connect_count = len(connect_calls)
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
assert len(connect_calls) == prev_connect_count, "No extra connect made for same source"
mock_info.assert_called_once_with("Set progress source to Scan 5")
def test_set_progress_source_disconnects_previous_device_subscription(
scan_progressbar, monkeypatch
):
disconnect_calls = []
monkeypatch.setattr(scan_progressbar.bec_dispatcher, "connect_slot", lambda *args: None)
monkeypatch.setattr(
scan_progressbar.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint: disconnect_calls.append(endpoint),
)
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device="motor1")
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device="motor2")
assert disconnect_calls == [MessageEndpoints.device_progress(device="motor1")]
def test_set_progress_source_disconnects_device_when_switching_to_scan(
scan_progressbar, monkeypatch
):
disconnect_calls = []
monkeypatch.setattr(scan_progressbar.bec_dispatcher, "connect_slot", lambda *args: None)
monkeypatch.setattr(
scan_progressbar.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint: disconnect_calls.append(endpoint),
)
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device="motor1")
scan_progressbar.set_progress_source(ProgressSource.SCAN_PROGRESS)
assert disconnect_calls == [MessageEndpoints.device_progress(device="motor1")]
def test_cleanup_disconnects_active_device_subscription(scan_progressbar, monkeypatch):
def test_cleanup_disconnects_active_scan_subscription(scan_progressbar, monkeypatch):
disconnect_calls = []
@@ -240,148 +232,28 @@ def test_cleanup_disconnects_active_device_subscription(scan_progressbar, monkey
monkeypatch.setattr(scan_progressbar.progressbar, "deleteLater", lambda: None)
monkeypatch.setattr(BECWidget, "cleanup", lambda self: None)
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device="motor1")
with (
mock.patch.object(scan_progressbar, "close", wraps=scan_progressbar.close) as close_mock,
mock.patch.object(
scan_progressbar, "deleteLater", wraps=scan_progressbar.deleteLater
) as delete_later_mock,
):
ScanProgressBar.cleanup(scan_progressbar)
assert disconnect_calls == [MessageEndpoints.scan_progress(), MessageEndpoints.scan_status()]
assert scan_progressbar.progress_tracker._connected is False
close_mock.assert_not_called()
delete_later_mock.assert_not_called()
def test_cleanup_stops_active_task(scan_progressbar, monkeypatch):
monkeypatch.setattr(BECWidget, "cleanup", lambda self: None)
scan_progressbar.progress_tracker.task = ProgressTask(parent=scan_progressbar)
scan_progressbar.progress_tracker._active_scan_id = "scan-1"
timer = scan_progressbar.progress_tracker.task.timer
ScanProgressBar.cleanup(scan_progressbar)
assert disconnect_calls == [
MessageEndpoints.device_progress(device="motor1"),
MessageEndpoints.scan_queue_status(),
]
assert scan_progressbar._progress_source is None
assert scan_progressbar._progress_device is None
def test_progressbar_queue_update(scan_progressbar):
"""
Test that an empty queue update does not change the progress source.
"""
msg = messages.ScanQueueStatusMessage(
queue={"primary": messages.ScanQueueStatus(info=[], status="RUNNING")}
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_not_called()
def test_progressbar_queue_update_with_scan(scan_progressbar, scan_message):
"""
Test that a queue update with a scan changes the progress source to SCAN_PROGRESS.
"""
request_block = messages.RequestBlock(
msg=scan_message,
RID="some-rid",
scan_motors=["samx"],
readout_priority={"monitored": ["samx"]},
is_scan=True,
scan_number=1,
scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9",
report_instructions=[{"scan_progress": 20}],
)
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": messages.ScanQueueStatus(
info=[
messages.QueueInfoEntry(
queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964",
scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
status="RUNNING",
active_request_block=request_block,
is_scan=[True],
request_blocks=[request_block],
scan_number=[1],
)
],
status="RUNNING",
)
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_called_once_with(ProgressSource.SCAN_PROGRESS)
def test_progressbar_queue_update_with_device(scan_progressbar, scan_message):
"""
Test that a queue update with a device changes the progress source to DEVICE_PROGRESS.
"""
request_block = messages.RequestBlock(
msg=scan_message,
RID="some-rid",
scan_motors=["samx"],
readout_priority={"monitored": ["samx"]},
is_scan=True,
scan_number=1,
scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9",
report_instructions=[{"device_progress": ["samx"]}],
)
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": messages.ScanQueueStatus(
info=[
messages.QueueInfoEntry(
queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964",
scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
status="RUNNING",
active_request_block=request_block,
is_scan=[True],
request_blocks=[request_block],
scan_number=[1],
)
],
status="RUNNING",
)
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_called_once_with(ProgressSource.DEVICE_PROGRESS, device="samx")
def test_progressbar_queue_update_with_no_scan_or_device(scan_progressbar, scan_message):
"""
Test that a queue update with neither scan nor device does not change the progress source.
"""
request_block = messages.RequestBlock(
msg=scan_message,
RID="some-rid",
scan_motors=["samx"],
readout_priority={"monitored": ["samx"]},
is_scan=True,
scan_number=1,
scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9",
)
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": messages.ScanQueueStatus(
info=[
messages.QueueInfoEntry(
queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964",
scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
status="RUNNING",
active_request_block=request_block,
is_scan=[True],
request_blocks=[request_block],
scan_number=[1],
)
],
status="RUNNING",
)
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_not_called()
assert not timer.isActive()
assert scan_progressbar.progress_tracker.task is None
assert scan_progressbar.progress_tracker._active_scan_id is None
+56
View File
@@ -16,8 +16,12 @@ from qtpy.QtWidgets import (
)
from bec_widgets.utils.widget_io import WidgetHierarchy, WidgetIO, WidgetTreeNode
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
from .client_mocks import mocked_client
@pytest.fixture(scope="function")
def example_widget(qtbot):
@@ -196,6 +200,58 @@ def test_widget_io_signal(qtbot, example_widget):
assert changes[-1][1] == False
def test_widget_io_device_combobox_handler(qtbot, mocked_client):
widget = DeviceComboBox(client=mocked_client)
qtbot.addWidget(widget)
changes = []
WidgetIO.connect_widget_change_signal(widget, lambda _widget, value: changes.append(value))
WidgetIO.set_value(widget, "samx")
assert WidgetIO.get_value(widget) == "samx"
assert changes[-1] == "samx"
def test_widget_io_device_combobox_handler_accepts_subclasses(qtbot, mocked_client):
class PromotedDeviceComboBox(DeviceComboBox):
pass
widget = PromotedDeviceComboBox(client=mocked_client)
qtbot.addWidget(widget)
WidgetIO.set_value(widget, "samx")
assert WidgetIO.get_value(widget) == "samx"
def test_widget_io_signal_combobox_handler(qtbot, mocked_client):
widget = SignalComboBox(client=mocked_client, require_device=True)
qtbot.addWidget(widget)
changes = []
widget.set_device("samx")
WidgetIO.connect_widget_change_signal(widget, lambda _widget, value: changes.append(value))
WidgetIO.set_value(widget, "samx")
assert WidgetIO.get_value(widget) == "samx"
widget.setCurrentText("")
widget.setCurrentText("samx")
assert changes[-1] == "samx"
def test_widget_io_signal_combobox_handler_accepts_subclasses(qtbot, mocked_client):
class PromotedSignalComboBox(SignalComboBox):
pass
widget = PromotedSignalComboBox(client=mocked_client, require_device=True)
qtbot.addWidget(widget)
widget.set_device("samx")
WidgetIO.set_value(widget, "samx")
assert WidgetIO.get_value(widget) == "samx"
def test_find_widgets(example_widget):
# Test find_widgets by class type
line_edits = WidgetIO.find_widgets(QLineEdit)