mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-06-13 00:20:57 +02:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
|
||||
@@ -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)
|
||||
@@ -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