mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-06-13 08:32:00 +02:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9213360c44 | |||
| d2cbd84479 | |||
| 3dfed232ef | |||
| 64ed28ba4f | |||
| 434f9f561f | |||
| 768c138576 | |||
| 9550866b67 | |||
| 64cbf93d64 | |||
| 4bb7e811dd | |||
| 08650e86a3 | |||
| 563603b80e | |||
| d07d03c1be | |||
| 6aa1f7e74a | |||
| 2546cc484d | |||
| b20897f4bf | |||
| 7e6dca4912 | |||
| f6f590cabd | |||
| f78bc26a26 | |||
| aca2c4a7a5 | |||
| 6db198e684 |
@@ -1,6 +1,72 @@
|
||||
# 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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -32,6 +32,7 @@ _Widgets = {
|
||||
"BECQueue": "BECQueue",
|
||||
"BECShell": "BECShell",
|
||||
"BECStatusBox": "BECStatusBox",
|
||||
"BeamlineStateManager": "BeamlineStateManager",
|
||||
"BecConsole": "BecConsole",
|
||||
"DapComboBox": "DapComboBox",
|
||||
"DeviceBrowser": "DeviceBrowser",
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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())
|
||||
@@ -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)
|
||||
@@ -0,0 +1,63 @@
|
||||
from pathlib import Path
|
||||
|
||||
from qtpy.QtCore import QUrl
|
||||
from qtpy.QtMultimedia import QAudioOutput, QMediaPlayer
|
||||
from qtpy.QtWidgets import QApplication, QComboBox, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class SoundPlayerWidget(QWidget):
|
||||
"""Simple widget to preview bundled sound assets."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.setWindowTitle("Sound Player")
|
||||
|
||||
self._sounds_dir = Path(__file__).resolve().parent.parent / "assets" / "sounds"
|
||||
self._player = QMediaPlayer(self)
|
||||
self._audio_output = QAudioOutput(self)
|
||||
self._player.setAudioOutput(self._audio_output)
|
||||
|
||||
self.sound_combo_box = QComboBox(self)
|
||||
self.play_button = QPushButton("Play", self)
|
||||
|
||||
self._populate_sounds()
|
||||
self.play_button.clicked.connect(self.play_selected_sound)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.sound_combo_box)
|
||||
layout.addWidget(self.play_button)
|
||||
|
||||
self.resize(420, 100)
|
||||
|
||||
def _populate_sounds(self) -> None:
|
||||
"""Load bundled sound assets into the combo box."""
|
||||
sound_files = sorted(self._sounds_dir.glob("*.mp3"))
|
||||
for sound_file in sound_files:
|
||||
self.sound_combo_box.addItem(sound_file.stem, str(sound_file))
|
||||
|
||||
self.play_button.setEnabled(bool(sound_files))
|
||||
if not sound_files:
|
||||
self.sound_combo_box.addItem("No sounds found")
|
||||
|
||||
def play_selected_sound(self) -> None:
|
||||
"""Play the currently selected sound asset."""
|
||||
sound_path = self.sound_combo_box.currentData()
|
||||
if not sound_path:
|
||||
return
|
||||
|
||||
self._player.setSource(QUrl.fromLocalFile(sound_path))
|
||||
self._player.stop()
|
||||
self._player.play()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
|
||||
widget = SoundPlayerWidget()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -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)
|
||||
|
||||
+47
-34
@@ -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 theme‑updated 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,
|
||||
@@ -417,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,
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "3.14.0"
|
||||
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",
|
||||
@@ -74,6 +75,7 @@ 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()
|
||||
@@ -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)
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [])
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user