mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 09:47:52 +02:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54dd0a9913 | ||
| 3146d98c57 | |||
| a3ffcefe80 | |||
|
|
1a7052073d | ||
| 235aabf307 | |||
|
|
c1cb69b0e8 | ||
| 11131ef14c | |||
| 5e4c129af6 | |||
| 4d8c07cdd1 | |||
| 8f4c8e45b3 | |||
| 5623547e92 | |||
| be73349c70 | |||
| 1a350c3b16 | |||
| 138d4cabbd | |||
| b0d03c0648 | |||
| a9613a07b0 | |||
| 886964bb54 | |||
| 7fc85bac7f | |||
| d626caae3d | |||
| dea2568de3 | |||
| a55f561971 | |||
| 9ce31c9833 | |||
|
|
95ce98c622 | ||
| 187bf493a5 | |||
| 1612933dd9 | |||
|
|
8c3d6334f6 | ||
| 30acc4c236 | |||
| 0dec78afba | |||
| 57b9a57a63 | |||
| 644be621f2 | |||
|
|
d07265b86d | ||
| f0d48a0508 | |||
| af8db0bede | |||
|
|
0ae4b652a4 | ||
| 32fd959e67 | |||
|
|
73b1886bb8 | ||
| 9f853b0864 |
146
CHANGELOG.md
146
CHANGELOG.md
@@ -1,6 +1,152 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.16.2 (2025-06-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **waveform**: Asyncsignal are handled with the same update mechanism as async readback
|
||||
([`a3ffcef`](https://github.com/bec-project/bec_widgets/commit/a3ffcefe8085fa1a88d679f8ef6adfdff786492e))
|
||||
|
||||
### Testing
|
||||
|
||||
- **utils**: Dmmock can fetch get_bec_signals method
|
||||
([`3146d98`](https://github.com/bec-project/bec_widgets/commit/3146d98c572ff2bb8ab77f71b75d9612e364ffe0))
|
||||
|
||||
|
||||
## v2.16.1 (2025-06-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scatter**: Fix tab order
|
||||
([`235aabf`](https://github.com/bec-project/bec_widgets/commit/235aabf307ef0c01a51a5cd8be4eb53915ed360c))
|
||||
|
||||
|
||||
## v2.16.0 (2025-06-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Adjust height of list widget
|
||||
([`11131ef`](https://github.com/bec-project/bec_widgets/commit/11131ef14c7e8714a4eaf70256da9e5835d60810))
|
||||
|
||||
- Make website test robust
|
||||
([`4d8c07c`](https://github.com/bec-project/bec_widgets/commit/4d8c07cdd142bab4c0d8224c43e66517a02da7c1))
|
||||
|
||||
- Parse config on submission and reload after
|
||||
([`5e4c129`](https://github.com/bec-project/bec_widgets/commit/5e4c129af6ae6644e4bb94f4129c6770fd26542d))
|
||||
|
||||
- Pass on kwargs from PydanticModelForm
|
||||
([`a55f561`](https://github.com/bec-project/bec_widgets/commit/a55f561971a9ce2295cd835cd5cb6ce436d6c693))
|
||||
|
||||
- Put waiting in thread
|
||||
([`1a350c3`](https://github.com/bec-project/bec_widgets/commit/1a350c3b16da0d990afd53d14934040e5e063177))
|
||||
|
||||
- Reset dict table properly
|
||||
([`5623547`](https://github.com/bec-project/bec_widgets/commit/5623547e926b86eeb5e2164fa6ec9e36b99b8f63))
|
||||
|
||||
- Scale dict widget height
|
||||
([`dea2568`](https://github.com/bec-project/bec_widgets/commit/dea2568de370450ca871fe7bf3573eec9acf8122))
|
||||
|
||||
- Tidy up form widget formatting
|
||||
([`8f4c8e4`](https://github.com/bec-project/bec_widgets/commit/8f4c8e45b3d4a15c67e36cd52d475c3117eca1d3))
|
||||
|
||||
### Features
|
||||
|
||||
- Add a widget to edit lists in forms
|
||||
([`7fc85ba`](https://github.com/bec-project/bec_widgets/commit/7fc85bac7fff8555b73d28eefe9a538540d574b9))
|
||||
|
||||
- Add set form item
|
||||
([`be73349`](https://github.com/bec-project/bec_widgets/commit/be73349c706582c144813f70dbc477372057de86))
|
||||
|
||||
- Allow editing device config from browser
|
||||
([`886964b`](https://github.com/bec-project/bec_widgets/commit/886964bb54d2f3923fb6baf198652bb05cf28eb2))
|
||||
|
||||
- Generate combobox for literal str
|
||||
([`138d4ca`](https://github.com/bec-project/bec_widgets/commit/138d4cabbd50e3c86ab18e9cdc25bbb5cdabc511))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
- Replace wait with waitUntil
|
||||
([`d626caa`](https://github.com/bec-project/bec_widgets/commit/d626caae3dc71683134cc47073bc131eba4820f5))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Move device config form to module
|
||||
([`9ce31c9`](https://github.com/bec-project/bec_widgets/commit/9ce31c9833ae38721b2246cdcac50f1154fba99d))
|
||||
|
||||
- Rename field widgets
|
||||
([`b0d03c0`](https://github.com/bec-project/bec_widgets/commit/b0d03c0648cd365143dfed27d4755d6f5b9c7a45))
|
||||
|
||||
### Testing
|
||||
|
||||
- Add tests for config dialog
|
||||
([`a9613a0`](https://github.com/bec-project/bec_widgets/commit/a9613a07b0cd9cd9455fd996d124c77218c9388f))
|
||||
|
||||
|
||||
## v2.15.1 (2025-06-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **main_window**: Added expiration timer for scroll label for ClientInfoMessage
|
||||
([`187bf49`](https://github.com/bec-project/bec_widgets/commit/187bf493a5b18299a10939901b9ed7e308435092))
|
||||
|
||||
- **scroll_label**: Updating label during scrolling is done imminently, regardless scrolling
|
||||
([`1612933`](https://github.com/bec-project/bec_widgets/commit/1612933dd9689f2bf480ad81811c051201a9ff70))
|
||||
|
||||
|
||||
## v2.15.0 (2025-06-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **main_window**: Central widget cleanup check to not remove None
|
||||
([`644be62`](https://github.com/bec-project/bec_widgets/commit/644be621f20cf09037da763f6217df9d1e4642bc))
|
||||
|
||||
### Features
|
||||
|
||||
- **main_window**: Main window can display the messages from the send_client_info as a scrolling
|
||||
horizontal text; closes #700
|
||||
([`0dec78a`](https://github.com/bec-project/bec_widgets/commit/0dec78afbaddbef98d20949d3a0ba4e0dc8529df))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **main_window**: App id is displayed as QLabel instead of message
|
||||
([`57b9a57`](https://github.com/bec-project/bec_widgets/commit/57b9a57a631f267a8cb3622bf73035ffb15510e6))
|
||||
|
||||
### Testing
|
||||
|
||||
- **main_window**: Becmainwindow tests extended
|
||||
([`30acc4c`](https://github.com/bec-project/bec_widgets/commit/30acc4c236bfbfed19f56512b264a52b4359e6c1))
|
||||
|
||||
|
||||
## v2.14.0 (2025-06-13)
|
||||
|
||||
### Features
|
||||
|
||||
- **image_roi**: Added EllipticalROI
|
||||
([`af8db0b`](https://github.com/bec-project/bec_widgets/commit/af8db0bede32dd10ad72671a8c2978ca884f4994))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **image_roi_tree**: Shape switch logic adjusted to reduce code repetition
|
||||
([`f0d48a0`](https://github.com/bec-project/bec_widgets/commit/f0d48a05085bb8c628e516d4a976d776ee63c7c3))
|
||||
|
||||
|
||||
## v2.13.2 (2025-06-13)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Allow sets in generated form types
|
||||
([`32fd959`](https://github.com/bec-project/bec_widgets/commit/32fd959e675108265f35139b44d02ba966bd37e2))
|
||||
|
||||
|
||||
## v2.13.1 (2025-06-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **main_window**: Event filter applied on QEvent.Type.StatusTip; closes #698
|
||||
([`9f853b0`](https://github.com/bec-project/bec_widgets/commit/9f853b08640f0ffff9f5b59c6d5e0dd3e210d4f6))
|
||||
|
||||
|
||||
## v2.13.0 (2025-06-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1044,6 +1044,128 @@ class DeviceLineEdit(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class EllipticalROI(RPCBase):
|
||||
"""Elliptical Region of Interest with centre/width/height tracking and auto-labelling."""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
|
||||
@label.setter
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def movable(self) -> "bool":
|
||||
"""
|
||||
Gets whether this ROI is movable.
|
||||
|
||||
Returns:
|
||||
bool: True if the ROI can be moved, False otherwise.
|
||||
"""
|
||||
|
||||
@movable.setter
|
||||
@rpc_call
|
||||
def movable(self) -> "bool":
|
||||
"""
|
||||
Gets whether this ROI is movable.
|
||||
|
||||
Returns:
|
||||
bool: True if the ROI can be moved, False otherwise.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def line_color(self) -> "str":
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
|
||||
@line_color.setter
|
||||
@rpc_call
|
||||
def line_color(self) -> "str":
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def line_width(self) -> "int":
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
|
||||
@line_width.setter
|
||||
@rpc_call
|
||||
def line_width(self) -> "int":
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple":
|
||||
"""
|
||||
Return the ellipse's centre and size.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True returns dict; otherwise tuple.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_data_from_image(
|
||||
self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
|
||||
):
|
||||
"""
|
||||
Wrapper around `pyqtgraph.ROI.getArrayRegion`.
|
||||
|
||||
Args:
|
||||
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
|
||||
the first `ImageItem` in the same GraphicsScene as this ROI.
|
||||
returnMappedCoords (bool): If True, also returns the coordinate array generated by
|
||||
*getArrayRegion*.
|
||||
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
|
||||
such as `axes`, `order`, `shape`, etc.
|
||||
|
||||
Returns:
|
||||
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_position(self, x: "float", y: "float"):
|
||||
"""
|
||||
Sets the position of the ROI.
|
||||
|
||||
Args:
|
||||
x (float): The x-coordinate of the new position.
|
||||
y (float): The y-coordinate of the new position.
|
||||
"""
|
||||
|
||||
|
||||
class Image(RPCBase):
|
||||
"""Image widget for displaying 2D data."""
|
||||
|
||||
@@ -1529,7 +1651,7 @@ class Image(RPCBase):
|
||||
@rpc_call
|
||||
def add_roi(
|
||||
self,
|
||||
kind: "Literal['rect', 'circle']" = "rect",
|
||||
kind: "Literal['rect', 'circle', 'ellipse']" = "rect",
|
||||
name: "str | None" = None,
|
||||
line_width: "int | None" = 5,
|
||||
pos: "tuple[float, float] | None" = (10, 10),
|
||||
|
||||
@@ -19,7 +19,7 @@ class FakeDevice(BECDevice):
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd.Device",
|
||||
"deviceConfig": {},
|
||||
"deviceTags": ["user device"],
|
||||
"deviceTags": {"user device"},
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
@@ -89,7 +89,7 @@ class FakePositioner(BECPositioner):
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd_devices.SimPositioner",
|
||||
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
|
||||
"deviceTags": ["user motors"],
|
||||
"deviceTags": {"user motors"},
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
@@ -210,6 +210,39 @@ class DMMock:
|
||||
for device in devices:
|
||||
self.devices[device.name] = device
|
||||
|
||||
def get_bec_signals(self, signal_class_name: str):
|
||||
"""
|
||||
Emulate DeviceManager.get_bec_signals for unit-tests.
|
||||
|
||||
For “AsyncSignal” we list every device whose readout_priority is
|
||||
ReadoutPriority.ASYNC and build a minimal tuple
|
||||
(device_name, signal_name, signal_info_dict) that matches the real
|
||||
API shape used by Waveform._check_async_signal_found.
|
||||
"""
|
||||
signals: list[tuple[str, str, dict]] = []
|
||||
if signal_class_name != "AsyncSignal":
|
||||
return signals
|
||||
|
||||
for device in self.devices.values():
|
||||
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
|
||||
device_name = device.name
|
||||
signal_name = device.name # primary signal in our mocks
|
||||
signal_info = {
|
||||
"component_name": signal_name,
|
||||
"obj_name": signal_name,
|
||||
"kind_str": "hinted",
|
||||
"signal_class": signal_class_name,
|
||||
"metadata": {
|
||||
"connected": True,
|
||||
"precision": None,
|
||||
"read_access": True,
|
||||
"timestamp": 0.0,
|
||||
"write_access": True,
|
||||
},
|
||||
}
|
||||
signals.append((device_name, signal_name, signal_info))
|
||||
return signals
|
||||
|
||||
|
||||
DEVICES = [
|
||||
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
|
||||
|
||||
@@ -37,6 +37,16 @@ class ExpandableGroupFrame(QFrame):
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._create_title_layout(title, icon)
|
||||
|
||||
self._contents = QWidget(self)
|
||||
self._layout.addWidget(self._contents)
|
||||
|
||||
self._expansion_button.clicked.connect(self.switch_expanded_state)
|
||||
self.expanded = self._expanded # type: ignore
|
||||
self.expansion_state_changed.emit()
|
||||
|
||||
def _create_title_layout(self, title: str, icon: str):
|
||||
self._title_layout = QHBoxLayout()
|
||||
self._layout.addLayout(self._title_layout)
|
||||
|
||||
@@ -45,6 +55,8 @@ class ExpandableGroupFrame(QFrame):
|
||||
self._title_layout.addWidget(self._title_icon)
|
||||
self._title_layout.addWidget(self._title)
|
||||
self.icon_name = icon
|
||||
self._title.clicked.connect(self.switch_expanded_state)
|
||||
self._title_icon.clicked.connect(self.switch_expanded_state)
|
||||
|
||||
self._title_layout.addStretch(1)
|
||||
|
||||
@@ -52,13 +64,6 @@ class ExpandableGroupFrame(QFrame):
|
||||
self._update_expansion_icon()
|
||||
self._title_layout.addWidget(self._expansion_button, stretch=1)
|
||||
|
||||
self._contents = QWidget(self)
|
||||
self._layout.addWidget(self._contents)
|
||||
|
||||
self._expansion_button.clicked.connect(self.switch_expanded_state)
|
||||
self.expanded = self._expanded # type: ignore
|
||||
self.expansion_state_changed.emit()
|
||||
|
||||
def set_layout(self, layout: QLayout) -> None:
|
||||
self._contents.setLayout(layout)
|
||||
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import NoneType
|
||||
from typing import NamedTuple
|
||||
|
||||
@@ -8,11 +7,12 @@ from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QSizePolicy, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.utils.forms_from_types import styles
|
||||
from bec_widgets.utils.forms_from_types.items import (
|
||||
DynamicFormItem,
|
||||
DynamicFormItemType,
|
||||
@@ -68,15 +68,11 @@ class TypedForm(BECWidget, QWidget):
|
||||
logger.error("Must specify one and only one of items and form_item_specs!")
|
||||
items = []
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
self._items = (
|
||||
form_item_specs
|
||||
if form_item_specs is not None
|
||||
else [
|
||||
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
|
||||
for name, item_type in items # type: ignore
|
||||
]
|
||||
)
|
||||
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
self._items = form_item_specs or [
|
||||
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
|
||||
for name, item_type in items # type: ignore
|
||||
]
|
||||
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
@@ -84,15 +80,19 @@ class TypedForm(BECWidget, QWidget):
|
||||
self._enabled: bool = enabled
|
||||
|
||||
self._form_grid_container = QWidget(parent=self)
|
||||
self._form_grid_container.setSizePolicy(
|
||||
QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding
|
||||
)
|
||||
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid = QWidget(parent=self._form_grid_container)
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._layout.addWidget(self._form_grid_container)
|
||||
self._form_grid_container.setLayout(QVBoxLayout())
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
|
||||
self._widget_types: dict | None = None
|
||||
self._widget_from_type = widget_from_type
|
||||
self._post_init()
|
||||
|
||||
def _post_init(self):
|
||||
"""Override this if a subclass should do things after super().__init__ and before populate()"""
|
||||
self.populate()
|
||||
self.enabled = self._enabled # type: ignore # QProperty
|
||||
|
||||
@@ -100,6 +100,8 @@ class TypedForm(BECWidget, QWidget):
|
||||
self._clear_grid()
|
||||
for r, item in enumerate(self._items):
|
||||
self._add_griditem(item, r)
|
||||
gl: QGridLayout = self._form_grid.layout()
|
||||
gl.setRowStretch(gl.rowCount(), 1)
|
||||
|
||||
def _add_griditem(self, item: FormItemSpec, row: int):
|
||||
grid = self._form_grid.layout()
|
||||
@@ -107,16 +109,16 @@ class TypedForm(BECWidget, QWidget):
|
||||
label.setProperty("_model_field_name", item.name)
|
||||
label.setToolTip(item.info.description or item.name)
|
||||
grid.addWidget(label, row, 0)
|
||||
widget = widget_from_type(item.item_type)(parent=self, spec=item)
|
||||
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
|
||||
widget.valueChanged.connect(self.value_changed)
|
||||
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
grid.addWidget(widget, row, 1)
|
||||
|
||||
def enumerate_form_widgets(self):
|
||||
"""Return a generator over the rows of the form, with the row number, the label widget (to
|
||||
which the field name is attached as a property), and the entry widget"""
|
||||
which the field name is attached as a property "_model_field_name"), and the entry widget"""
|
||||
grid: QGridLayout = self._form_grid.layout() # type: ignore
|
||||
for i in range(grid.rowCount()):
|
||||
for i in range(grid.rowCount() - 1): # One extra row for stretch
|
||||
yield GridRow(i, grid.itemAtPosition(i, 0).widget(), grid.itemAtPosition(i, 1).widget())
|
||||
|
||||
def _dict_from_grid(self) -> dict[str, DynamicFormItemType]:
|
||||
@@ -135,7 +137,7 @@ class TypedForm(BECWidget, QWidget):
|
||||
old_layout.deleteLater()
|
||||
self._form_grid.deleteLater()
|
||||
self._form_grid = QWidget()
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
self._form_grid_container.layout().addWidget(self._form_grid)
|
||||
|
||||
@@ -186,14 +188,18 @@ class PydanticModelForm(TypedForm):
|
||||
|
||||
Args:
|
||||
data_model (type[BaseModel]): the model class for which to generate a form.
|
||||
enabled (bool): whether fields are enabled for editing.
|
||||
enabled (bool, optional): whether fields are enabled for editing.
|
||||
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
|
||||
|
||||
"""
|
||||
self._pretty_display = pretty_display
|
||||
self._md_schema = data_model
|
||||
super().__init__(
|
||||
parent=parent, form_item_specs=self._form_item_specs(), enabled=enabled, client=client
|
||||
parent=parent,
|
||||
form_item_specs=self._form_item_specs(),
|
||||
enabled=enabled,
|
||||
client=client,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._validity = CompactPopupWidget()
|
||||
@@ -207,6 +213,18 @@ class PydanticModelForm(TypedForm):
|
||||
self._layout.addWidget(self._validity)
|
||||
self.value_changed.connect(self.validate_form)
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
def set_pretty_display_theme(self, theme: str = "dark"):
|
||||
if self._pretty_display:
|
||||
self.setStyleSheet(styles.pretty_display_theme(theme))
|
||||
|
||||
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.set_pretty_display_theme) # type: ignore
|
||||
|
||||
def set_schema(self, schema: type[BaseModel]):
|
||||
self._md_schema = schema
|
||||
self.populate()
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from types import GenericAlias, UnionType
|
||||
from typing import Literal
|
||||
from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic.fields import FieldInfo
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from pydantic_core import PydanticUndefined
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtCore import QSize, Qt, Signal # type: ignore
|
||||
from qtpy.QtGui import QFontMetrics
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
|
||||
from bec_widgets.widgets.editors.scan_metadata._util import (
|
||||
clearable_required,
|
||||
@@ -36,6 +47,7 @@ from bec_widgets.widgets.editors.scan_metadata._util import (
|
||||
field_minlen,
|
||||
field_precision,
|
||||
)
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -64,12 +76,12 @@ class FormItemSpec(BaseModel):
|
||||
if isinstance(v, (type, UnionType)):
|
||||
return v
|
||||
if isinstance(v, GenericAlias):
|
||||
if v.__origin__ in [list, dict] and all(
|
||||
if v.__origin__ in [list, dict, set] and all(
|
||||
arg in allowed_primitives for arg in v.__args__
|
||||
):
|
||||
return v
|
||||
raise ValueError(
|
||||
f"Generics of type {v} are not supported - only lists and dicts of primitive types {allowed_primitives}"
|
||||
f"Generics of type {v} are not supported - only lists, dicts and sets of primitive types {allowed_primitives}"
|
||||
)
|
||||
if type(v) is type(Literal[""]): # _LiteralGenericAlias is not exported from typing
|
||||
arg_types = set(type(arg) for arg in v.__args__)
|
||||
@@ -123,7 +135,7 @@ class ClearableBoolEntry(QWidget):
|
||||
self._false.setToolTip(tooltip)
|
||||
|
||||
|
||||
DynamicFormItemType = str | int | float | Decimal | bool | dict
|
||||
DynamicFormItemType = str | int | float | Decimal | bool | dict | list | None
|
||||
|
||||
|
||||
class DynamicFormItem(QWidget):
|
||||
@@ -146,8 +158,9 @@ class DynamicFormItem(QWidget):
|
||||
self._desc = self._spec.info.description
|
||||
self.setLayout(self._layout)
|
||||
self._add_main_widget()
|
||||
self._main_widget: QWidget
|
||||
self._main_widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
|
||||
self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
if not spec.pretty_display:
|
||||
if clearable_required(spec.info):
|
||||
self._add_clear_button()
|
||||
@@ -167,6 +180,8 @@ class DynamicFormItem(QWidget):
|
||||
|
||||
def _set_pretty_display(self):
|
||||
self.setEnabled(False)
|
||||
if button := getattr(self, "_clear_button", None):
|
||||
button.setVisible(False)
|
||||
|
||||
def _describe(self, pad=" "):
|
||||
return pad + (self._desc if self._desc else "")
|
||||
@@ -185,7 +200,7 @@ class DynamicFormItem(QWidget):
|
||||
self.valueChanged.emit()
|
||||
|
||||
|
||||
class StrMetadataField(DynamicFormItem):
|
||||
class StrFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
@@ -210,11 +225,11 @@ class StrMetadataField(DynamicFormItem):
|
||||
|
||||
def setValue(self, value: str):
|
||||
if value is None:
|
||||
self._main_widget.setText("")
|
||||
return self._main_widget.setText("")
|
||||
self._main_widget.setText(str(value))
|
||||
|
||||
|
||||
class IntMetadataField(DynamicFormItem):
|
||||
class IntFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
@@ -243,7 +258,7 @@ class IntMetadataField(DynamicFormItem):
|
||||
self._main_widget.setValue(value)
|
||||
|
||||
|
||||
class FloatDecimalMetadataField(DynamicFormItem):
|
||||
class FloatDecimalFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
@@ -277,7 +292,7 @@ class FloatDecimalMetadataField(DynamicFormItem):
|
||||
self._main_widget.setValue(float(value))
|
||||
|
||||
|
||||
class BoolMetadataField(DynamicFormItem):
|
||||
class BoolFormItem(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.stateChanged.connect(self._value_changed)
|
||||
@@ -298,10 +313,26 @@ class BoolMetadataField(DynamicFormItem):
|
||||
self._main_widget.setChecked(value)
|
||||
|
||||
|
||||
class DictMetadataField(DynamicFormItem):
|
||||
class BoolToggleFormItem(BoolFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
if spec.info.default is PydanticUndefined:
|
||||
spec.info.default = False
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = ToggleSwitch()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._main_widget.setToolTip(self._describe(""))
|
||||
if self._default is not None:
|
||||
self._main_widget.setChecked(self._default)
|
||||
|
||||
|
||||
class DictFormItem(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.data_changed.connect(self._value_changed)
|
||||
if spec.info.default is not PydanticUndefined:
|
||||
self._main_widget.set_default(spec.info.default)
|
||||
|
||||
def _set_pretty_display(self):
|
||||
self._main_widget.set_button_visibility(False)
|
||||
@@ -319,44 +350,263 @@ class DictMetadataField(DynamicFormItem):
|
||||
self._main_widget.replace_data(value)
|
||||
|
||||
|
||||
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
|
||||
if annotation in [str, str | None]:
|
||||
return StrMetadataField
|
||||
if annotation in [int, int | None]:
|
||||
return IntMetadataField
|
||||
if annotation in [float, float | None, Decimal, Decimal | None]:
|
||||
return FloatDecimalMetadataField
|
||||
if annotation in [bool, bool | None]:
|
||||
return BoolMetadataField
|
||||
if annotation in [dict, dict | None] or (
|
||||
isinstance(annotation, GenericAlias) and annotation.__origin__ is dict
|
||||
):
|
||||
return DictMetadataField
|
||||
if annotation in [list, list | None] or (
|
||||
isinstance(annotation, GenericAlias) and annotation.__origin__ is list
|
||||
):
|
||||
return StrMetadataField
|
||||
else:
|
||||
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
|
||||
return StrMetadataField
|
||||
class _ItemAndWidgetType(NamedTuple):
|
||||
# TODO: this should be generic but not supported in 3.10
|
||||
item: type[int | float | str]
|
||||
widget: type[QWidget]
|
||||
default: int | float | str
|
||||
|
||||
|
||||
class ListFormItem(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
if spec.info.annotation is list:
|
||||
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
||||
elif isinstance(spec.info.annotation, GenericAlias):
|
||||
args = set(typing.get_args(spec.info.annotation))
|
||||
if args == {str}:
|
||||
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
||||
if args == {int}:
|
||||
self._types = _ItemAndWidgetType(int, QSpinBox, 0)
|
||||
if args == {float} or args == {int, float}:
|
||||
self._types = _ItemAndWidgetType(float, QDoubleSpinBox, 0.0)
|
||||
else:
|
||||
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self._main_widget: QListWidget
|
||||
self._data = []
|
||||
self._min_lines = 2 if spec.pretty_display else 4
|
||||
self._repop(self._data)
|
||||
|
||||
def sizeHint(self):
|
||||
default = super().sizeHint()
|
||||
return QSize(default.width(), QFontMetrics(self.font()).height() * 6)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QListWidget()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self._add_buttons()
|
||||
|
||||
def _add_buttons(self):
|
||||
self._button_holder = QWidget()
|
||||
self._buttons = QVBoxLayout()
|
||||
self._button_holder.setLayout(self._buttons)
|
||||
self._layout.addWidget(self._button_holder)
|
||||
self._add_button = QPushButton("+")
|
||||
self._add_button.setToolTip("add a new row")
|
||||
self._remove_button = QPushButton("-")
|
||||
self._remove_button.setToolTip("delete the focused row (if any)")
|
||||
self._add_button.clicked.connect(self._add_row)
|
||||
self._remove_button.clicked.connect(self._delete_row)
|
||||
self._buttons.addWidget(self._add_button)
|
||||
self._buttons.addWidget(self._remove_button)
|
||||
|
||||
def _set_pretty_display(self):
|
||||
super()._set_pretty_display()
|
||||
self._button_holder.setHidden(True)
|
||||
|
||||
def _repop(self, data):
|
||||
self._main_widget.clear()
|
||||
for val in data:
|
||||
self._add_list_item(val)
|
||||
self.scale_to_data()
|
||||
|
||||
def _add_data_item(self, val=None):
|
||||
val = val or self._types.default
|
||||
self._data.append(val)
|
||||
self._add_list_item(val)
|
||||
self._repop(self._data)
|
||||
|
||||
def _add_list_item(self, val):
|
||||
item = QListWidgetItem(self._main_widget)
|
||||
item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable)
|
||||
item_widget = self._types.widget(parent=self)
|
||||
WidgetIO.set_value(item_widget, val)
|
||||
self._main_widget.setItemWidget(item, item_widget)
|
||||
self._main_widget.addItem(item)
|
||||
WidgetIO.connect_widget_change_signal(item_widget, self._update)
|
||||
return item_widget
|
||||
|
||||
def _update(self, _, value, *args):
|
||||
self._data[self._main_widget.currentRow()] = value
|
||||
|
||||
@SafeSlot()
|
||||
def _add_row(self):
|
||||
self._add_data_item(self._types.default)
|
||||
self._repop(self._data)
|
||||
|
||||
@SafeSlot()
|
||||
def _delete_row(self):
|
||||
if selected := self._main_widget.currentItem():
|
||||
self._main_widget.removeItemWidget(selected)
|
||||
row = self._main_widget.currentRow()
|
||||
self._main_widget.takeItem(row)
|
||||
self._data.pop(row)
|
||||
self._repop(self._data)
|
||||
|
||||
@SafeSlot()
|
||||
def clear(self):
|
||||
self._repop([])
|
||||
|
||||
def getValue(self):
|
||||
return self._data
|
||||
|
||||
def setValue(self, value: Iterable):
|
||||
if set(map(type, value)) | {self._types.item} != {self._types.item}:
|
||||
raise ValueError(f"This widget only accepts items of type {self._types.item}")
|
||||
self._data = list(value)
|
||||
self._repop(self._data)
|
||||
|
||||
def _line_height(self):
|
||||
return QFontMetrics(self._main_widget.font()).height()
|
||||
|
||||
def set_max_height_in_lines(self, lines: int):
|
||||
outer_inc = 1 if self._spec.pretty_display else 3
|
||||
self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines))
|
||||
self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1))
|
||||
self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc))
|
||||
|
||||
def scale_to_data(self, *_):
|
||||
self.set_max_height_in_lines(self._main_widget.count() + 1)
|
||||
|
||||
|
||||
class SetFormItem(ListFormItem):
|
||||
def _add_main_widget(self) -> None:
|
||||
super()._add_main_widget()
|
||||
self._add_item_field = self._types.widget()
|
||||
self._buttons.addWidget(QLabel("Add new:"))
|
||||
self._buttons.addWidget(self._add_item_field)
|
||||
self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Minimum)
|
||||
|
||||
@SafeSlot()
|
||||
def _add_row(self):
|
||||
self._add_data_item(WidgetIO.get_value(self._add_item_field))
|
||||
self._repop(self._data)
|
||||
|
||||
def _update(self, _, value, *args):
|
||||
if value in self._data:
|
||||
return
|
||||
return super()._update(_, value, *args)
|
||||
|
||||
def _add_data_item(self, val=None):
|
||||
val = val or self._types.default
|
||||
if val == self._types.default or val in self._data:
|
||||
return
|
||||
self._data.append(val)
|
||||
self._add_list_item(val)
|
||||
|
||||
def _add_list_item(self, val):
|
||||
item_widget = super()._add_list_item(val)
|
||||
if isinstance(item_widget, QLineEdit):
|
||||
item_widget.setReadOnly(True)
|
||||
return item_widget
|
||||
|
||||
def getValue(self):
|
||||
return set(self._data)
|
||||
|
||||
def setValue(self, value: set):
|
||||
return super().setValue(set(value))
|
||||
|
||||
|
||||
class StrLiteralFormItem(DynamicFormItem):
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QComboBox()
|
||||
self._options = get_args(self._spec.info.annotation)
|
||||
for opt in self._options:
|
||||
self._main_widget.addItem(opt)
|
||||
self._layout.addWidget(self._main_widget)
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.currentText()
|
||||
|
||||
def setValue(self, value: str | None):
|
||||
if value is None:
|
||||
self.clear()
|
||||
for i in range(self._main_widget.count()):
|
||||
if self._main_widget.itemText(i) == value:
|
||||
self._main_widget.setCurrentIndex(i)
|
||||
return
|
||||
raise ValueError(f"Cannot set value: {value}, options are: {self._options}")
|
||||
|
||||
def clear(self):
|
||||
self._main_widget.setCurrentIndex(-1)
|
||||
|
||||
|
||||
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
|
||||
|
||||
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
|
||||
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
|
||||
# and delete/insert keys or change the order
|
||||
"literal_str": (
|
||||
lambda spec: type(spec.info.annotation) is type(Literal[""])
|
||||
and set(type(arg) for arg in get_args(spec.info.annotation)) == {str},
|
||||
StrLiteralFormItem,
|
||||
),
|
||||
"str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem),
|
||||
"int": (lambda spec: spec.item_type in [int, int | None], IntFormItem),
|
||||
"float_decimal": (
|
||||
lambda spec: spec.item_type in [float, float | None, Decimal, Decimal | None],
|
||||
FloatDecimalFormItem,
|
||||
),
|
||||
"bool": (lambda spec: spec.item_type in [bool, bool | None], BoolFormItem),
|
||||
"dict": (
|
||||
lambda spec: spec.item_type in [dict, dict | None]
|
||||
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is dict),
|
||||
DictFormItem,
|
||||
),
|
||||
"list": (
|
||||
lambda spec: spec.item_type in [list, list | None]
|
||||
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is list),
|
||||
ListFormItem,
|
||||
),
|
||||
"set": (
|
||||
lambda spec: spec.item_type in [set, set | None]
|
||||
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is set),
|
||||
SetFormItem,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def widget_from_type(
|
||||
spec: FormItemSpec, widget_types: WidgetTypeRegistry | None = None
|
||||
) -> type[DynamicFormItem]:
|
||||
widget_types = widget_types or DEFAULT_WIDGET_TYPES
|
||||
for predicate, widget_type in widget_types.values():
|
||||
if predicate(spec):
|
||||
return widget_type
|
||||
logger.warning(
|
||||
f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation."
|
||||
)
|
||||
return StrFormItem
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
class TestModel(BaseModel):
|
||||
value0: set = Field(set(["a", "b"]))
|
||||
value1: str | None = Field(None)
|
||||
value2: bool | None = Field(None)
|
||||
value3: bool = Field(True)
|
||||
value4: int = Field(123)
|
||||
value5: int | None = Field()
|
||||
value6: list[int] = Field()
|
||||
value7: list = Field()
|
||||
|
||||
app = QApplication([])
|
||||
w = QWidget()
|
||||
layout = QGridLayout()
|
||||
w.setLayout(layout)
|
||||
items = []
|
||||
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
|
||||
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
|
||||
layout.addWidget(QLabel(field_name), i, 0)
|
||||
layout.addWidget(widget_from_type(info.annotation)(info), i, 1)
|
||||
widg = widget_from_type(spec)(spec=spec)
|
||||
items.append(widg)
|
||||
layout.addWidget(widg, i, 1)
|
||||
|
||||
items[6].setValue([1, 2, 3, 4])
|
||||
items[7].setValue(["1", "2", "asdfg", "qwerty"])
|
||||
|
||||
w.show()
|
||||
app.exec()
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtGui import QFontMetrics, QPainter
|
||||
from qtpy.QtWidgets import QLabel
|
||||
|
||||
|
||||
class ScrollLabel(QLabel):
|
||||
"""A QLabel that scrolls its text horizontally across the widget."""
|
||||
|
||||
def __init__(self, parent=None, speed_ms=30, step_px=1, delay_ms=2000):
|
||||
super().__init__(parent=parent)
|
||||
self._offset = 0
|
||||
self._text_width = 0
|
||||
|
||||
# scrolling timer (runs continuously once started)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(speed_ms)
|
||||
self._timer.timeout.connect(self._scroll)
|
||||
|
||||
# delay‑before‑scroll timer (single‑shot)
|
||||
self._delay_timer = QTimer(self)
|
||||
self._delay_timer.setSingleShot(True)
|
||||
self._delay_timer.setInterval(delay_ms)
|
||||
self._delay_timer.timeout.connect(self._timer.start)
|
||||
|
||||
self._step_px = step_px
|
||||
|
||||
def setText(self, text):
|
||||
"""
|
||||
Overridden to ensure that new text replaces the current one
|
||||
immediately.
|
||||
If the label was already scrolling (or in its delay phase),
|
||||
the next message starts **without** the extra delay.
|
||||
"""
|
||||
# Determine whether the widget was already in a scrolling cycle
|
||||
was_scrolling = self._timer.isActive() or self._delay_timer.isActive()
|
||||
|
||||
super().setText(text)
|
||||
|
||||
fm = QFontMetrics(self.font())
|
||||
self._text_width = fm.horizontalAdvance(text)
|
||||
self._offset = 0
|
||||
|
||||
# Skip the delay when we were already scrolling
|
||||
self._update_timer(skip_delay=was_scrolling)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._update_timer()
|
||||
|
||||
def _update_timer(self, *, skip_delay: bool = False):
|
||||
"""
|
||||
Decide whether to start or stop scrolling.
|
||||
|
||||
If the text is wider than the visible area, start a single‑shot
|
||||
delay timer (2s by default). Scrolling begins only after this
|
||||
delay. Any change (resize or new text) restarts the logic.
|
||||
"""
|
||||
needs_scroll = self._text_width > self.width()
|
||||
|
||||
if needs_scroll:
|
||||
# Reset any running timers
|
||||
if self._timer.isActive():
|
||||
self._timer.stop()
|
||||
if self._delay_timer.isActive():
|
||||
self._delay_timer.stop()
|
||||
|
||||
self._offset = 0
|
||||
|
||||
# Start scrolling immediately when we should skip the delay,
|
||||
# otherwise apply the configured delay_ms interval
|
||||
if skip_delay:
|
||||
self._timer.start()
|
||||
else:
|
||||
self._delay_timer.start()
|
||||
else:
|
||||
if self._delay_timer.isActive():
|
||||
self._delay_timer.stop()
|
||||
if self._timer.isActive():
|
||||
self._timer.stop()
|
||||
self.update()
|
||||
|
||||
def _scroll(self):
|
||||
self._offset += self._step_px
|
||||
if self._offset >= self._text_width:
|
||||
self._offset = 0
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.TextAntialiasing)
|
||||
text = self.text()
|
||||
if not text:
|
||||
return
|
||||
fm = QFontMetrics(self.font())
|
||||
y = (self.height() + fm.ascent() - fm.descent()) // 2
|
||||
if self._text_width <= self.width():
|
||||
painter.drawText(0, y, text)
|
||||
else:
|
||||
x = -self._offset
|
||||
gap = 50 # space between repeating text blocks
|
||||
while x < self.width():
|
||||
painter.drawText(x, y, text)
|
||||
x += self._text_width + gap
|
||||
|
||||
def cleanup(self):
|
||||
"""Stop all timers to prevent memory leaks."""
|
||||
if self._timer.isActive():
|
||||
self._timer.stop()
|
||||
if self._delay_timer.isActive():
|
||||
self._delay_timer.stop()
|
||||
@@ -1,16 +1,17 @@
|
||||
import os
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QActionGroup, QIcon
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
|
||||
from qtpy.QtWidgets import QApplication, QFrame, QLabel, QMainWindow, QStyle, QVBoxLayout, QWidget
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
|
||||
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
@@ -36,6 +37,14 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self._init_ui()
|
||||
self._connect_to_theme_change()
|
||||
|
||||
# Connections to BEC Notifications
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.display_client_message, MessageEndpoints.client_info()
|
||||
)
|
||||
|
||||
################################################################################
|
||||
# MainWindow Elements Initialization
|
||||
################################################################################
|
||||
def _init_ui(self):
|
||||
|
||||
# Set the icon
|
||||
@@ -43,40 +52,77 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
|
||||
# Set Menu and Status bar
|
||||
self._setup_menu_bar()
|
||||
self._init_status_bar_widgets()
|
||||
|
||||
# BEC Specific UI
|
||||
self.display_app_id()
|
||||
|
||||
def _init_status_bar_widgets(self):
|
||||
"""
|
||||
Prepare the BEC specific widgets in the status bar.
|
||||
"""
|
||||
status_bar = self.statusBar()
|
||||
|
||||
# Left: App‑ID label
|
||||
self._app_id_label = QLabel()
|
||||
self._app_id_label.setAlignment(
|
||||
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
status_bar.addWidget(self._app_id_label)
|
||||
|
||||
# Add a separator after the app ID label
|
||||
self._add_separator()
|
||||
|
||||
# Centre: Client‑info label (stretch=1 so it expands)
|
||||
self._client_info_label = ScrollLabel()
|
||||
self._client_info_label.setAlignment(
|
||||
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
status_bar.addWidget(self._client_info_label, 1)
|
||||
|
||||
# Timer to automatically clear client messages once they expire
|
||||
self._client_info_expire_timer = QTimer(self)
|
||||
self._client_info_expire_timer.setSingleShot(True)
|
||||
self._client_info_expire_timer.timeout.connect(lambda: self._client_info_label.setText(""))
|
||||
|
||||
def _add_separator(self):
|
||||
"""
|
||||
Add a vertically centred separator to the status bar.
|
||||
"""
|
||||
status_bar = self.statusBar()
|
||||
|
||||
# The actual line
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.VLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
line.setFixedHeight(status_bar.sizeHint().height() - 2)
|
||||
|
||||
# Wrapper to center the line vertically -> work around for QFrame not being able to center itself
|
||||
wrapper = QWidget()
|
||||
vbox = QVBoxLayout(wrapper)
|
||||
vbox.setContentsMargins(0, 0, 0, 0)
|
||||
vbox.addStretch()
|
||||
vbox.addWidget(line, alignment=Qt.AlignHCenter)
|
||||
vbox.addStretch()
|
||||
wrapper.setFixedWidth(line.sizeHint().width())
|
||||
|
||||
status_bar.addWidget(wrapper)
|
||||
|
||||
def _init_bec_icon(self):
|
||||
icon = self.app.windowIcon()
|
||||
if icon.isNull():
|
||||
print("No icon is set, setting default icon")
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
|
||||
size=QSize(48, 48),
|
||||
)
|
||||
self.app.setWindowIcon(icon)
|
||||
else:
|
||||
print("An icon is set")
|
||||
|
||||
def load_ui(self, ui_file):
|
||||
loader = UILoader(self)
|
||||
self.ui = loader.loader(ui_file)
|
||||
self.setCentralWidget(self.ui)
|
||||
|
||||
def display_app_id(self):
|
||||
"""
|
||||
Display the app ID in the status bar.
|
||||
"""
|
||||
if self.bec_dispatcher.cli_server is None:
|
||||
status_message = "Not connected"
|
||||
else:
|
||||
# Get the server ID from the dispatcher
|
||||
server_id = self.bec_dispatcher.cli_server.gui_id
|
||||
status_message = f"App ID: {server_id}"
|
||||
self.statusBar().showMessage(status_message)
|
||||
|
||||
def _fetch_theme(self) -> str:
|
||||
return self.app.theme.theme
|
||||
|
||||
@@ -164,14 +210,64 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
help_menu.addAction(widgets_docs)
|
||||
help_menu.addAction(bug_report)
|
||||
|
||||
################################################################################
|
||||
# Status Bar Addons
|
||||
################################################################################
|
||||
def display_app_id(self):
|
||||
"""
|
||||
Display the app ID in the status bar.
|
||||
"""
|
||||
if self.bec_dispatcher.cli_server is None:
|
||||
status_message = "Not connected"
|
||||
else:
|
||||
# Get the server ID from the dispatcher
|
||||
server_id = self.bec_dispatcher.cli_server.gui_id
|
||||
status_message = f"App ID: {server_id}"
|
||||
self._app_id_label.setText(status_message)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def display_client_message(self, msg: dict, meta: dict):
|
||||
"""
|
||||
Display a client message in the status bar.
|
||||
|
||||
Args:
|
||||
msg(dict): The message to display, should contain:
|
||||
meta(dict): Metadata about the message, usually empty.
|
||||
"""
|
||||
# self._client_info_label.setText("")
|
||||
message = msg.get("message", "")
|
||||
expiration = msg.get("expire", 0) # 0 → never expire
|
||||
self._client_info_label.setText(message)
|
||||
|
||||
# Restart the expiration timer if necessary
|
||||
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
|
||||
self._client_info_expire_timer.stop()
|
||||
if expiration and expiration > 0:
|
||||
self._client_info_expire_timer.start(int(expiration * 1000))
|
||||
|
||||
################################################################################
|
||||
# General and Cleanup Methods
|
||||
################################################################################
|
||||
@SafeSlot(str)
|
||||
def change_theme(self, theme: str):
|
||||
"""
|
||||
Change the theme of the application.
|
||||
|
||||
Args:
|
||||
theme(str): The theme to apply, either "light" or "dark".
|
||||
"""
|
||||
apply_theme(theme)
|
||||
|
||||
def event(self, event):
|
||||
if event.type() == QEvent.Type.StatusTip:
|
||||
return True
|
||||
return super().event(event)
|
||||
|
||||
def cleanup(self):
|
||||
central_widget = self.centralWidget()
|
||||
central_widget.close()
|
||||
central_widget.deleteLater()
|
||||
if central_widget is not None:
|
||||
central_widget.close()
|
||||
central_widget.deleteLater()
|
||||
if not isinstance(central_widget, BECWidget):
|
||||
# if the central widget is not a BECWidget, we need to call the cleanup method
|
||||
# of all widgets whose parent is the current BECMainWindow
|
||||
@@ -182,8 +278,22 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
child.cleanup()
|
||||
child.close()
|
||||
child.deleteLater()
|
||||
|
||||
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
|
||||
self._client_info_expire_timer.stop()
|
||||
# Status bar widgets cleanup
|
||||
self._client_info_label.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
class UILaunchWindow(BECMainWindow):
|
||||
RPC = True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
main_window = UILaunchWindow()
|
||||
main_window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any
|
||||
|
||||
from qtpy import QtWidgets
|
||||
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
|
||||
from qtpy.QtGui import QFontMetrics
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QHBoxLayout,
|
||||
@@ -14,7 +15,9 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
_NOT_SET = object()
|
||||
|
||||
|
||||
class DictBackedTableModel(QAbstractTableModel):
|
||||
@@ -26,6 +29,7 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
data (list[list[str]]): list of key-value pairs to initialise with"""
|
||||
super().__init__()
|
||||
self._data: list[list[str]] = data
|
||||
self._default = _NOT_SET
|
||||
self._disallowed_keys: list[str] = []
|
||||
|
||||
# pylint: disable=missing-function-docstring
|
||||
@@ -51,7 +55,10 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
Qt.ItemDataRole.EditRole,
|
||||
Qt.ItemDataRole.ToolTipRole,
|
||||
]:
|
||||
return str(self._data[index.row()][index.column()])
|
||||
try:
|
||||
return str(self._data[index.row()][index.column()])
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def setData(self, index, value, role):
|
||||
if role == Qt.ItemDataRole.EditRole:
|
||||
@@ -63,9 +70,10 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
return False
|
||||
|
||||
def replaceData(self, data: dict):
|
||||
self.delete_rows(list(range(len(self._data))))
|
||||
self.resetInternalData()
|
||||
self._data = [[k, v] for k, v in data.items()]
|
||||
self.dataChanged.emit(self.index(0, 0), self.index(len(self._data), 0))
|
||||
self._data = [[str(k), str(v)] for k, v in data.items()]
|
||||
self.dataChanged.emit(self.index(0, 0), self.index(len(self._data), 1))
|
||||
|
||||
def update_disallowed_keys(self, keys: list[str]):
|
||||
"""Set the list of keys which may not be used.
|
||||
@@ -76,7 +84,7 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
for i, item in enumerate(self._data):
|
||||
if item[0] in self._disallowed_keys:
|
||||
self._data[i][0] = ""
|
||||
self.dataChanged.emit(self.index(i, 0), self.index(i, 0))
|
||||
self.dataChanged.emit(self.index(i, 0), self.index(i, 1))
|
||||
|
||||
def _other_keys(self, row: int):
|
||||
return [r[0] for r in self._data[:row] + self._data[row + 1 :]]
|
||||
@@ -105,24 +113,39 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
@SafeSlot()
|
||||
def add_row(self):
|
||||
self.insertRow(self.rowCount())
|
||||
self.dataChanged.emit(self.index(self.rowCount(), 0), self.index(self.rowCount(), 1), 0)
|
||||
|
||||
@SafeSlot(list)
|
||||
def delete_rows(self, rows: list[int]):
|
||||
# delete from the end so indices stay correct
|
||||
for row in sorted(rows, reverse=True):
|
||||
self.dataChanged.emit(self.index(row, 0), self.index(row, 1), 0)
|
||||
self.removeRows(row, 1, QModelIndex())
|
||||
|
||||
def set_default(self, value: dict | None):
|
||||
self._default = value
|
||||
|
||||
def dump_dict(self):
|
||||
if self._data == [[]]:
|
||||
if self._data in [[], [[]], [["", ""]]]:
|
||||
if self._default is not _NOT_SET:
|
||||
return self._default
|
||||
return {}
|
||||
return dict(self._data)
|
||||
|
||||
def length(self):
|
||||
return len(self._data)
|
||||
|
||||
|
||||
class DictBackedTable(QWidget):
|
||||
delete_rows = Signal(list)
|
||||
data_changed = Signal(dict)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, initial_data: list[list[str]] = []):
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
initial_data: list[list[str]] = [],
|
||||
autoscale_to_data: bool = True,
|
||||
):
|
||||
"""Widget which uses a DictBackedTableModel to display an editable table
|
||||
which can be extracted as a dict.
|
||||
|
||||
@@ -133,15 +156,25 @@ class DictBackedTable(QWidget):
|
||||
|
||||
self._layout = QHBoxLayout()
|
||||
self.setLayout(self._layout)
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self._table_model = DictBackedTableModel(initial_data)
|
||||
self._table_view = QTreeView()
|
||||
|
||||
self._table_view.setModel(self._table_model)
|
||||
self._min_lines = 3
|
||||
self.set_height_in_lines(len(initial_data))
|
||||
self._table_view.setSizePolicy(
|
||||
QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||||
)
|
||||
self._table_view.setAlternatingRowColors(True)
|
||||
self._table_view.setUniformRowHeights(True)
|
||||
self._table_view.setWordWrap(False)
|
||||
self._table_view.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
|
||||
self._table_view.header().setSectionResizeMode(5, QtWidgets.QHeaderView.Stretch)
|
||||
self.autoscale = autoscale_to_data
|
||||
if self.autoscale:
|
||||
self.data_changed.connect(self.scale_to_data)
|
||||
self._layout.addWidget(self._table_view)
|
||||
|
||||
self._button_holder = QWidget()
|
||||
@@ -157,8 +190,12 @@ class DictBackedTable(QWidget):
|
||||
self._add_button.clicked.connect(self._table_model.add_row)
|
||||
self._remove_button.clicked.connect(self.delete_selected_rows)
|
||||
self.delete_rows.connect(self._table_model.delete_rows)
|
||||
|
||||
self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict()))
|
||||
|
||||
def set_default(self, value: dict | None):
|
||||
self._table_model.set_default(value)
|
||||
|
||||
def set_button_visibility(self, value: bool):
|
||||
self._button_holder.setVisible(value)
|
||||
|
||||
@@ -166,8 +203,8 @@ class DictBackedTable(QWidget):
|
||||
def clear(self):
|
||||
self._table_model.replaceData({})
|
||||
|
||||
def replace_data(self, data: dict):
|
||||
self._table_model.replaceData(data)
|
||||
def replace_data(self, data: dict | None):
|
||||
self._table_model.replaceData(data or {})
|
||||
|
||||
def delete_selected_rows(self):
|
||||
"""Delete rows which are part of the selection model"""
|
||||
@@ -187,6 +224,29 @@ class DictBackedTable(QWidget):
|
||||
keys (list[str]): list of keys which are forbidden."""
|
||||
self._table_model.update_disallowed_keys(keys)
|
||||
|
||||
def set_height_in_lines(self, lines: int):
|
||||
self._table_view.setMaximumHeight(
|
||||
int(QFontMetrics(self._table_view.font()).height() * max(lines + 2, self._min_lines))
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(dict)
|
||||
def scale_to_data(self, *_):
|
||||
self.set_height_in_lines(self._table_model.length())
|
||||
|
||||
@SafeProperty(bool)
|
||||
def autoscale(self): # type: ignore
|
||||
return self._autoscale
|
||||
|
||||
@autoscale.setter
|
||||
def autoscale(self, autoscale: bool):
|
||||
self._autoscale = autoscale
|
||||
if self._autoscale:
|
||||
self.scale_to_data()
|
||||
self.data_changed.connect(self.scale_to_data)
|
||||
else:
|
||||
self.data_changed.disconnect(self.scale_to_data)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
@@ -67,4 +67,6 @@ def field_default(info: FieldInfo):
|
||||
|
||||
|
||||
def clearable_required(info: FieldInfo):
|
||||
return type(None) in get_args(info.annotation) or info.is_required()
|
||||
return type(None) in get_args(info.annotation) or (
|
||||
info.is_required() and info.default is PydanticUndefined
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||
BaseROI,
|
||||
CircularROI,
|
||||
EllipticalROI,
|
||||
RectangularROI,
|
||||
ROIController,
|
||||
)
|
||||
@@ -554,7 +555,7 @@ class ImageBase(PlotBase):
|
||||
|
||||
def add_roi(
|
||||
self,
|
||||
kind: Literal["rect", "circle"] = "rect",
|
||||
kind: Literal["rect", "circle", "ellipse"] = "rect",
|
||||
name: str | None = None,
|
||||
line_width: int | None = 5,
|
||||
pos: tuple[float, float] | None = (10, 10),
|
||||
@@ -599,6 +600,16 @@ class ImageBase(PlotBase):
|
||||
movable=movable,
|
||||
**pg_kwargs,
|
||||
)
|
||||
elif kind == "ellipse":
|
||||
roi = EllipticalROI(
|
||||
pos=pos,
|
||||
size=size,
|
||||
parent_image=self,
|
||||
line_width=line_width,
|
||||
label=name,
|
||||
movable=movable,
|
||||
**pg_kwargs,
|
||||
)
|
||||
else:
|
||||
raise ValueError("kind must be 'rect' or 'circle'")
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||
BaseROI,
|
||||
CircularROI,
|
||||
EllipticalROI,
|
||||
RectangularROI,
|
||||
ROIController,
|
||||
)
|
||||
@@ -121,11 +122,21 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
# --------------------------------------------------------------------- UI
|
||||
def _init_toolbar(self):
|
||||
tb = ModularToolBar(self, self, orientation="horizontal")
|
||||
self._draw_actions: dict[str, MaterialIconAction] = {}
|
||||
# --- ROI draw actions (toggleable) ---
|
||||
self.add_rect_action = MaterialIconAction("add_box", "Add Rect ROI", True, self)
|
||||
self.add_circle_action = MaterialIconAction("add_circle", "Add Circle ROI", True, self)
|
||||
tb.add_action("Add Rect ROI", self.add_rect_action, self)
|
||||
self._draw_actions["rect"] = self.add_rect_action
|
||||
self.add_circle_action = MaterialIconAction("add_circle", "Add Circle ROI", True, self)
|
||||
tb.add_action("Add Circle ROI", self.add_circle_action, self)
|
||||
self._draw_actions["circle"] = self.add_circle_action
|
||||
# --- Ellipse ROI draw action ---
|
||||
self.add_ellipse_action = MaterialIconAction("vignette", "Add Ellipse ROI", True, self)
|
||||
tb.add_action("Add Ellipse ROI", self.add_ellipse_action, self)
|
||||
self._draw_actions["ellipse"] = self.add_ellipse_action
|
||||
|
||||
for mode, act in self._draw_actions.items():
|
||||
act.action.toggled.connect(lambda on, m=mode: self._on_draw_action_toggled(m, on))
|
||||
|
||||
# Expand/Collapse toggle
|
||||
self.expand_toggle = MaterialIconAction(
|
||||
@@ -174,17 +185,9 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap))
|
||||
|
||||
# ROI drawing state
|
||||
self._roi_draw_mode = None # 'rect' | 'circle' | None
|
||||
self._roi_draw_mode = None # 'rect' | 'circle' | 'ellipse' | None
|
||||
self._roi_start_pos = None # QPointF in image coords
|
||||
self._temp_roi = None # live ROI being resized while dragging
|
||||
|
||||
# toggle handlers
|
||||
self.add_rect_action.action.toggled.connect(
|
||||
lambda on: self._set_roi_draw_mode("rect" if on else None)
|
||||
)
|
||||
self.add_circle_action.action.toggled.connect(
|
||||
lambda on: self._set_roi_draw_mode("circle" if on else None)
|
||||
)
|
||||
# capture mouse events on the plot scene
|
||||
self.plot.scene().installEventFilter(self)
|
||||
|
||||
@@ -214,16 +217,12 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
return str(value)
|
||||
|
||||
def _set_roi_draw_mode(self, mode: str | None):
|
||||
# Ensure only the selected action is toggled on
|
||||
if mode == "rect":
|
||||
self.add_rect_action.action.setChecked(True)
|
||||
self.add_circle_action.action.setChecked(False)
|
||||
elif mode == "circle":
|
||||
self.add_rect_action.action.setChecked(False)
|
||||
self.add_circle_action.action.setChecked(True)
|
||||
else:
|
||||
self.add_rect_action.action.setChecked(False)
|
||||
self.add_circle_action.action.setChecked(False)
|
||||
# Update toolbar actions so that only the selected mode is checked
|
||||
for m, act in self._draw_actions.items():
|
||||
act.action.blockSignals(True)
|
||||
act.action.setChecked(m == mode)
|
||||
act.action.blockSignals(False)
|
||||
|
||||
self._roi_draw_mode = mode
|
||||
self._roi_start_pos = None
|
||||
# remove any unfinished temp ROI
|
||||
@@ -231,6 +230,15 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
self.plot.removeItem(self._temp_roi)
|
||||
self._temp_roi = None
|
||||
|
||||
def _on_draw_action_toggled(self, mode: str, checked: bool):
|
||||
if checked:
|
||||
# Activate selected mode
|
||||
self._set_roi_draw_mode(mode)
|
||||
else:
|
||||
# If the active mode is being unchecked, clear mode
|
||||
if self._roi_draw_mode == mode:
|
||||
self._set_roi_draw_mode(None)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if self._roi_draw_mode is None:
|
||||
return super().eventFilter(obj, event)
|
||||
@@ -243,12 +251,18 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
parent_image=self.image_widget,
|
||||
resize_handles=False,
|
||||
)
|
||||
if self._roi_draw_mode == "circle":
|
||||
elif self._roi_draw_mode == "circle":
|
||||
self._temp_roi = CircularROI(
|
||||
pos=[self._roi_start_pos.x() - 2.5, self._roi_start_pos.y() - 2.5],
|
||||
size=[5, 5],
|
||||
parent_image=self.image_widget,
|
||||
)
|
||||
elif self._roi_draw_mode == "ellipse":
|
||||
self._temp_roi = EllipticalROI(
|
||||
pos=[self._roi_start_pos.x() - 2.5, self._roi_start_pos.y() - 2.5],
|
||||
size=[5, 5],
|
||||
parent_image=self.image_widget,
|
||||
)
|
||||
self.plot.addItem(self._temp_roi)
|
||||
return True
|
||||
elif event.type() == QEvent.GraphicsSceneMouseMove and self._temp_roi is not None:
|
||||
@@ -258,13 +272,19 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
|
||||
if self._roi_draw_mode == "rect":
|
||||
self._temp_roi.setSize([dx, dy])
|
||||
if self._roi_draw_mode == "circle":
|
||||
elif self._roi_draw_mode == "circle":
|
||||
r = max(
|
||||
1, math.hypot(dx, dy)
|
||||
) # radius never smaller than 1 for safety of handle mapping, otherwise SEGFAULT
|
||||
d = 2 * r # diameter
|
||||
self._temp_roi.setPos(self._roi_start_pos.x() - r, self._roi_start_pos.y() - r)
|
||||
self._temp_roi.setSize([d, d])
|
||||
elif self._roi_draw_mode == "ellipse":
|
||||
# Safeguard: enforce a minimum ellipse width/height of 2 px
|
||||
min_dim = 2.0
|
||||
w = dx if abs(dx) >= min_dim else math.copysign(min_dim, dx or 1.0)
|
||||
h = dy if abs(dy) >= min_dim else math.copysign(min_dim, dy or 1.0)
|
||||
self._temp_roi.setSize([w, h])
|
||||
return True
|
||||
elif (
|
||||
event.type() == QEvent.GraphicsSceneMouseRelease
|
||||
|
||||
@@ -750,6 +750,92 @@ class CircularROI(BaseROI, pg.CircleROI):
|
||||
return None
|
||||
|
||||
|
||||
class EllipticalROI(BaseROI, pg.EllipseROI):
|
||||
"""
|
||||
Elliptical Region of Interest with centre/width/height tracking and auto-labelling.
|
||||
|
||||
Mirrors the behaviour of ``CircularROI`` but supports independent
|
||||
horizontal and vertical radii.
|
||||
"""
|
||||
|
||||
centerChanged = Signal(float, float, float, float) # cx, cy, width, height
|
||||
centerReleased = Signal(float, float, float, float)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
pos,
|
||||
size,
|
||||
pen=None,
|
||||
config: ConnectionConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
parent_image: Image | None = None,
|
||||
label: str | None = None,
|
||||
line_color: str | None = None,
|
||||
line_width: int = 5,
|
||||
movable: bool = True,
|
||||
**extra_pg,
|
||||
):
|
||||
super().__init__(
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
parent_image=parent_image,
|
||||
label=label,
|
||||
line_color=line_color,
|
||||
line_width=line_width,
|
||||
pos=pos,
|
||||
size=size,
|
||||
pen=pen,
|
||||
movable=movable,
|
||||
**extra_pg,
|
||||
)
|
||||
|
||||
self.sigRegionChanged.connect(self._on_region_changed)
|
||||
self._adorner = LabelAdorner(self)
|
||||
self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
|
||||
self.handleHoverPen = fn.mkPen("lime", width=4)
|
||||
|
||||
def add_scale_handle(self):
|
||||
"""Add scale handles to the elliptical ROI."""
|
||||
self._addHandles() # delegates to pg.EllipseROI
|
||||
|
||||
def _on_region_changed(self):
|
||||
w = abs(self.state["size"][0])
|
||||
h = abs(self.state["size"][1])
|
||||
cx = self.pos().x() + w / 2
|
||||
cy = self.pos().y() + h / 2
|
||||
self.centerChanged.emit(cx, cy, w, h)
|
||||
self.parent_plot_item.vb.update()
|
||||
|
||||
def mouseDragEvent(self, ev):
|
||||
super().mouseDragEvent(ev)
|
||||
if ev.isFinish():
|
||||
w = abs(self.state["size"][0])
|
||||
h = abs(self.state["size"][1])
|
||||
cx = self.pos().x() + w / 2
|
||||
cy = self.pos().y() + h / 2
|
||||
self.centerReleased.emit(cx, cy, w, h)
|
||||
|
||||
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
|
||||
"""
|
||||
Return the ellipse's centre and size.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True returns dict; otherwise tuple.
|
||||
"""
|
||||
if typed is None:
|
||||
typed = self.description
|
||||
|
||||
w, h = map(abs, self.state["size"]) # raw diameters
|
||||
major, minor = (w, h) if w >= h else (h, w)
|
||||
cx = self.pos().x() + w / 2
|
||||
cy = self.pos().y() + h / 2
|
||||
|
||||
if typed:
|
||||
return {"center_x": cx, "center_y": cy, "major_axis": major, "minor_axis": minor}
|
||||
return (cx, cy, major, minor)
|
||||
|
||||
|
||||
class ROIController(QObject):
|
||||
"""Manages a collection of ROIs (Regions of Interest) with palette-assigned colors.
|
||||
|
||||
|
||||
@@ -141,6 +141,14 @@
|
||||
<header>bec_color_map_widget</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<tabstops>
|
||||
<tabstop>x_name</tabstop>
|
||||
<tabstop>x_entry</tabstop>
|
||||
<tabstop>y_name</tabstop>
|
||||
<tabstop>y_entry</tabstop>
|
||||
<tabstop>z_name</tabstop>
|
||||
<tabstop>z_entry</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
|
||||
@@ -1246,6 +1246,23 @@ class Waveform(PlotBase):
|
||||
|
||||
self.request_dap_update.emit()
|
||||
|
||||
def _check_async_signal_found(self, name: str, signal: str) -> bool:
|
||||
"""
|
||||
Check if the async signal is found in the BEC device manager.
|
||||
|
||||
Args:
|
||||
name(str): The name of the async signal.
|
||||
signal(str): The entry of the async signal.
|
||||
|
||||
Returns:
|
||||
bool: True if the async signal is found, False otherwise.
|
||||
"""
|
||||
bec_async_signals = self.client.device_manager.get_bec_signals("AsyncSignal")
|
||||
for entry_name, _, entry_data in bec_async_signals:
|
||||
if entry_name == name and entry_data.get("obj_name") == signal:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _setup_async_curve(self, curve: Curve):
|
||||
"""
|
||||
Setup async curve.
|
||||
@@ -1254,20 +1271,40 @@ class Waveform(PlotBase):
|
||||
curve(Curve): The curve to set up.
|
||||
"""
|
||||
name = curve.config.signal.name
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_async_readback, MessageEndpoints.device_async_readback(self.old_scan_id, name)
|
||||
)
|
||||
signal = curve.config.signal.entry
|
||||
async_signal_found = self._check_async_signal_found(name, signal)
|
||||
|
||||
try:
|
||||
curve.clear_data()
|
||||
except KeyError:
|
||||
logger.warning(f"Curve {name} not found in plot item.")
|
||||
pass
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_async_readback,
|
||||
MessageEndpoints.device_async_readback(self.scan_id, name),
|
||||
from_start=True,
|
||||
cb_info={"scan_id": self.scan_id},
|
||||
)
|
||||
|
||||
# New endpoint for async signals
|
||||
if async_signal_found:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_async_readback,
|
||||
MessageEndpoints.device_async_signal(self.old_scan_id, name, signal),
|
||||
)
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_async_readback,
|
||||
MessageEndpoints.device_async_signal(self.scan_id, name, signal),
|
||||
from_start=True,
|
||||
cb_info={"scan_id": self.scan_id},
|
||||
)
|
||||
|
||||
# old endpoint
|
||||
else:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_async_readback,
|
||||
MessageEndpoints.device_async_readback(self.old_scan_id, name),
|
||||
)
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_async_readback,
|
||||
MessageEndpoints.device_async_readback(self.scan_id, name),
|
||||
from_start=True,
|
||||
cb_info={"scan_id": self.scan_id},
|
||||
)
|
||||
logger.info(f"Setup async curve {name}")
|
||||
|
||||
@SafeSlot(dict, dict, verify_sender=True)
|
||||
|
||||
@@ -87,14 +87,13 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
for device, device_obj in self.dev.items():
|
||||
item = QListWidgetItem(self.dev_list)
|
||||
device_item = DeviceItem(
|
||||
parent=self, device=device, icon=map_device_type_to_icon(device_obj)
|
||||
parent=self,
|
||||
device=device,
|
||||
devices=self.dev,
|
||||
icon=map_device_type_to_icon(device_obj),
|
||||
)
|
||||
|
||||
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
|
||||
|
||||
device_config = self.dev[device]._config # pylint: disable=protected-access
|
||||
device_item.set_display_config(device_config)
|
||||
tooltip = device_config.get("description", "")
|
||||
tooltip = self.dev[device]._config.get("description", "")
|
||||
device_item.setToolTip(tooltip)
|
||||
device_item.broadcast_size_hint.connect(item.setSizeHint)
|
||||
item.setSizeHint(device_item.sizeHint())
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
from ast import literal_eval
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QLabel,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
|
||||
DeviceConfigForm,
|
||||
)
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class _CommSignals(QObject):
|
||||
error = Signal(Exception)
|
||||
done = Signal()
|
||||
|
||||
|
||||
class _CommunicateUpdate(QRunnable):
|
||||
|
||||
def __init__(self, config_helper: ConfigHelper, device: str, config: dict) -> None:
|
||||
super().__init__()
|
||||
self.config_helper = config_helper
|
||||
self.device = device
|
||||
self.config = config
|
||||
self.signals = _CommSignals()
|
||||
|
||||
@SafeSlot()
|
||||
def run(self):
|
||||
try:
|
||||
timeout = self.config_helper.suggested_timeout_s(self.config)
|
||||
RID = self.config_helper.send_config_request(
|
||||
action="update", config={self.device: self.config}, wait_for_response=False
|
||||
)
|
||||
logger.info("Waiting for config reply")
|
||||
reply = self.config_helper.wait_for_config_reply(RID, timeout=timeout)
|
||||
self.config_helper.handle_update_reply(reply, RID, timeout)
|
||||
logger.info("Done updating config!")
|
||||
except Exception as e:
|
||||
self.signals.error.emit(e)
|
||||
finally:
|
||||
self.signals.done.emit()
|
||||
|
||||
|
||||
class DeviceConfigDialog(BECWidget, QDialog):
|
||||
RPC = False
|
||||
applied = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
device: str | None = None,
|
||||
config_helper: ConfigHelper | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._config_helper = config_helper or ConfigHelper(
|
||||
self.client.connector, self.client._service_name
|
||||
)
|
||||
self.threadpool = QThreadPool()
|
||||
self._device = device
|
||||
self.setWindowTitle(f"Edit config for: {device}")
|
||||
self._container = QStackedLayout()
|
||||
self._container.setStackingMode(QStackedLayout.StackAll)
|
||||
|
||||
self._layout = QVBoxLayout()
|
||||
user_warning = QLabel(
|
||||
"Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n"
|
||||
"Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc."
|
||||
)
|
||||
user_warning.setWordWrap(True)
|
||||
user_warning.setStyleSheet("QLabel { color: red; }")
|
||||
self._layout.addWidget(user_warning)
|
||||
self._add_form()
|
||||
self._add_overlay()
|
||||
self._add_buttons()
|
||||
|
||||
self.setLayout(self._container)
|
||||
self._overlay_widget.setVisible(False)
|
||||
|
||||
def _add_form(self):
|
||||
self._form_widget = QWidget()
|
||||
self._form_widget.setLayout(self._layout)
|
||||
self._form = DeviceConfigForm()
|
||||
self._layout.addWidget(self._form)
|
||||
|
||||
for row in self._form.enumerate_form_widgets():
|
||||
if row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE:
|
||||
row.widget._set_pretty_display()
|
||||
|
||||
self._fetch_config()
|
||||
self._fill_form()
|
||||
self._container.addWidget(self._form_widget)
|
||||
|
||||
def _add_overlay(self):
|
||||
self._overlay_widget = QWidget()
|
||||
self._overlay_widget.setStyleSheet("background-color:rgba(128,128,128,128);")
|
||||
self._overlay_widget.setAutoFillBackground(True)
|
||||
self._overlay_layout = QVBoxLayout()
|
||||
self._overlay_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._overlay_widget.setLayout(self._overlay_layout)
|
||||
|
||||
self._spinner = SpinnerWidget(parent=self)
|
||||
self._spinner.setMinimumSize(QSize(100, 100))
|
||||
self._overlay_layout.addWidget(self._spinner)
|
||||
self._container.addWidget(self._overlay_widget)
|
||||
|
||||
def _add_buttons(self):
|
||||
button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.Apply | QDialogButtonBox.Ok | QDialogButtonBox.Cancel
|
||||
)
|
||||
button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
self._layout.addWidget(button_box)
|
||||
|
||||
def _fetch_config(self):
|
||||
self._initial_config = {}
|
||||
if (
|
||||
self.client.device_manager is not None
|
||||
and self._device in self.client.device_manager.devices
|
||||
):
|
||||
self._initial_config = self.client.device_manager.devices.get(self._device)._config
|
||||
|
||||
def _fill_form(self):
|
||||
self._form.set_data(DeviceConfigModel.model_validate(self._initial_config))
|
||||
|
||||
def updated_config(self):
|
||||
new_config = self._form.get_form_data()
|
||||
diff = {
|
||||
k: v for k, v in new_config.items() if self._initial_config.get(k) != new_config.get(k)
|
||||
}
|
||||
if diff.get("deviceConfig") is not None:
|
||||
# TODO: special cased in some parts of device manager but not others, should
|
||||
# be removed in config update as with below issue
|
||||
diff["deviceConfig"].pop("device_access", None)
|
||||
# TODO: replace when https://github.com/bec-project/bec/issues/528 is resolved
|
||||
diff["deviceConfig"] = {
|
||||
k: literal_eval(str(v)) for k, v in diff["deviceConfig"].items()
|
||||
}
|
||||
return diff
|
||||
|
||||
@SafeSlot()
|
||||
def apply(self):
|
||||
self._process_update_action()
|
||||
self.applied.emit()
|
||||
|
||||
@SafeSlot()
|
||||
def accept(self):
|
||||
self._process_update_action()
|
||||
return super().accept()
|
||||
|
||||
def _process_update_action(self):
|
||||
updated_config = self.updated_config()
|
||||
if (device_name := updated_config.get("name")) == "":
|
||||
logger.warning("Can't create a device with no name!")
|
||||
elif set(updated_config.keys()) & set(DEVICE_CONF_KEYS.NON_UPDATABLE):
|
||||
logger.info(
|
||||
f"Removing old device {self._device} and adding new device {device_name or self._device} with modified config: {updated_config}"
|
||||
)
|
||||
else:
|
||||
self._update_device_config(updated_config)
|
||||
|
||||
def _update_device_config(self, config: dict):
|
||||
if self._device is None:
|
||||
return
|
||||
if config == {}:
|
||||
logger.info("No changes made to device config")
|
||||
return
|
||||
logger.info(f"Sending request to update device config: {config}")
|
||||
|
||||
self._start_waiting_display()
|
||||
communicate_update = _CommunicateUpdate(self._config_helper, self._device, config)
|
||||
communicate_update.signals.error.connect(self.update_error)
|
||||
communicate_update.signals.done.connect(self.update_done)
|
||||
self.threadpool.start(communicate_update)
|
||||
|
||||
@SafeSlot()
|
||||
def update_done(self):
|
||||
self._stop_waiting_display()
|
||||
self._fetch_config()
|
||||
self._fill_form()
|
||||
|
||||
@SafeSlot(Exception, popup_error=True)
|
||||
def update_error(self, e: Exception):
|
||||
raise RuntimeError("Failed to update device configuration") from e
|
||||
|
||||
def _start_waiting_display(self):
|
||||
self._overlay_widget.setVisible(True)
|
||||
self._spinner.start()
|
||||
QApplication.processEvents()
|
||||
|
||||
def _stop_waiting_display(self):
|
||||
self._overlay_widget.setVisible(False)
|
||||
self._spinner.stop()
|
||||
QApplication.processEvents()
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QLineEdit, QPushButton, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
dialog = None
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("light")
|
||||
widget = QWidget()
|
||||
widget.setLayout(QVBoxLayout())
|
||||
|
||||
device = QLineEdit()
|
||||
widget.layout().addWidget(device)
|
||||
|
||||
def _destroy_dialog(*_):
|
||||
nonlocal dialog
|
||||
dialog = None
|
||||
|
||||
def accept(*args):
|
||||
logger.success(f"submitted device config form {dialog} {args}")
|
||||
_destroy_dialog()
|
||||
|
||||
def _show_dialog(*_):
|
||||
nonlocal dialog
|
||||
if dialog is None:
|
||||
dialog = DeviceConfigDialog(device=device.text())
|
||||
dialog.accepted.connect(accept)
|
||||
dialog.rejected.connect(_destroy_dialog)
|
||||
dialog.open()
|
||||
|
||||
button = QPushButton("Show device dialog")
|
||||
widget.layout().addWidget(button)
|
||||
button.clicked.connect(_show_dialog)
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_name
|
||||
from bec_widgets.utils.forms_from_types import styles
|
||||
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
|
||||
from bec_widgets.utils.forms_from_types.items import (
|
||||
DEFAULT_WIDGET_TYPES,
|
||||
BoolFormItem,
|
||||
BoolToggleFormItem,
|
||||
)
|
||||
|
||||
|
||||
class DeviceConfigForm(PydanticModelForm):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(self, parent=None, client=None, pretty_display=False, **kwargs):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
data_model=DeviceConfigModel,
|
||||
pretty_display=pretty_display,
|
||||
client=client,
|
||||
**kwargs,
|
||||
)
|
||||
self._widget_types = DEFAULT_WIDGET_TYPES.copy()
|
||||
self._widget_types["bool"] = (lambda spec: spec.item_type is bool, BoolToggleFormItem)
|
||||
self._widget_types["optional_bool"] = (
|
||||
lambda spec: spec.item_type == bool | None,
|
||||
BoolFormItem,
|
||||
)
|
||||
self._validity.setVisible(False)
|
||||
self._connect_to_theme_change()
|
||||
self.populate()
|
||||
|
||||
def _post_init(self): ...
|
||||
|
||||
def set_pretty_display_theme(self, theme: str | None = None):
|
||||
if theme is None:
|
||||
theme = get_theme_name()
|
||||
self.setStyleSheet(styles.pretty_display_theme(theme))
|
||||
|
||||
def get_form_data(self):
|
||||
"""Get the entered metadata as a dict."""
|
||||
return self._md_schema.model_validate(super().get_form_data()).model_dump()
|
||||
|
||||
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.set_pretty_display_theme) # type: ignore
|
||||
|
||||
def set_schema(self, schema: type[BaseModel]):
|
||||
raise TypeError("This class doesn't support changing the schema")
|
||||
|
||||
def set_data(self, data: DeviceConfigModel): # type: ignore # This class locks the type
|
||||
super().set_data(data)
|
||||
@@ -3,59 +3,37 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
from bec_lib.devicemanager import DeviceContainer
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QMimeData, QSize, Qt, Signal
|
||||
from qtpy.QtGui import QDrag
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QToolButton, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_name
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
|
||||
from bec_widgets.utils.forms_from_types import styles
|
||||
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
DeviceConfigDialog,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
|
||||
DeviceConfigForm,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtGui import QMouseEvent
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceItemForm(PydanticModelForm):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(self, parent=None, client=None, pretty_display=False, **kwargs):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
data_model=DeviceConfigModel,
|
||||
pretty_display=pretty_display,
|
||||
client=client,
|
||||
**kwargs,
|
||||
)
|
||||
self._validity.setVisible(False)
|
||||
self._connect_to_theme_change()
|
||||
|
||||
def set_pretty_display_theme(self, theme: str | None = None):
|
||||
if theme is None:
|
||||
theme = get_theme_name()
|
||||
self.setStyleSheet(styles.pretty_display_theme(theme))
|
||||
|
||||
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.set_pretty_display_theme) # type: ignore
|
||||
|
||||
|
||||
class DeviceItem(ExpandableGroupFrame):
|
||||
broadcast_size_hint = Signal(QSize)
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent, device: str, icon: str = "") -> None:
|
||||
def __init__(self, parent, device: str, devices: DeviceContainer, icon: str = "") -> None:
|
||||
super().__init__(parent, title=device, expanded=False, icon=icon)
|
||||
|
||||
self.dev = devices
|
||||
self._drag_pos = None
|
||||
self._expanded_first_time = False
|
||||
self._data = None
|
||||
@@ -65,17 +43,29 @@ class DeviceItem(ExpandableGroupFrame):
|
||||
self.set_layout(layout)
|
||||
|
||||
self.adjustSize()
|
||||
self._title.clicked.connect(self.switch_expanded_state)
|
||||
self._title_icon.clicked.connect(self.switch_expanded_state)
|
||||
|
||||
def _create_title_layout(self, title: str, icon: str):
|
||||
super()._create_title_layout(title, icon)
|
||||
self.edit_button = QToolButton()
|
||||
self.edit_button.setIcon(
|
||||
material_icon(icon_name="edit", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self._title_layout.insertWidget(self._title_layout.count() - 1, self.edit_button)
|
||||
self.edit_button.clicked.connect(self._create_edit_dialog)
|
||||
|
||||
def _create_edit_dialog(self):
|
||||
dialog = DeviceConfigDialog(parent=self, device=self.device)
|
||||
dialog.accepted.connect(self._reload_config)
|
||||
dialog.applied.connect(self._reload_config)
|
||||
dialog.open()
|
||||
|
||||
@SafeSlot()
|
||||
def switch_expanded_state(self):
|
||||
if not self.expanded and not self._expanded_first_time:
|
||||
self._expanded_first_time = True
|
||||
self.form = DeviceItemForm(parent=self, pretty_display=True)
|
||||
self.form = DeviceConfigForm(parent=self, pretty_display=True)
|
||||
self._contents.layout().addWidget(self.form)
|
||||
if self._data:
|
||||
self.form.set_data(self._data)
|
||||
self._reload_config()
|
||||
self.broadcast_size_hint.emit(self.sizeHint())
|
||||
super().switch_expanded_state()
|
||||
if self._expanded_first_time:
|
||||
@@ -86,6 +76,10 @@ class DeviceItem(ExpandableGroupFrame):
|
||||
self.adjustSize()
|
||||
self.broadcast_size_hint.emit(self.sizeHint())
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def _reload_config(self, *_):
|
||||
self.set_display_config(self.dev[self.device]._config)
|
||||
|
||||
def set_display_config(self, config_dict: dict):
|
||||
"""Set the displayed information from a device config dict, which must conform to the
|
||||
bec_lib.atlas_models.Device config model."""
|
||||
@@ -118,29 +112,33 @@ class DeviceItem(ExpandableGroupFrame):
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
|
||||
DeviceConfigForm,
|
||||
)
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = QWidget()
|
||||
layout = QHBoxLayout()
|
||||
widget.setLayout(layout)
|
||||
item = DeviceItem("Device")
|
||||
mock_config = {
|
||||
"name": "Test Device",
|
||||
"enabled": True,
|
||||
"deviceClass": "FakeDeviceClass",
|
||||
"deviceConfig": {"kwarg1": "value1"},
|
||||
"readoutPriority": "baseline",
|
||||
"description": "A device for testing out a widget",
|
||||
"readOnly": True,
|
||||
"softwareTrigger": False,
|
||||
"deviceTags": {"tag1", "tag2", "tag3"},
|
||||
"userParameter": {"some_setting": "some_ value"},
|
||||
}
|
||||
item = DeviceItem(widget, "Device", {"Device": MagicMock(enabled=True, _config=mock_config)})
|
||||
layout.addWidget(DarkModeButton())
|
||||
layout.addWidget(item)
|
||||
item.set_display_config(
|
||||
{
|
||||
"name": "Test Device",
|
||||
"enabled": True,
|
||||
"deviceClass": "FakeDeviceClass",
|
||||
"deviceConfig": {"kwarg1": "value1"},
|
||||
"readoutPriority": "baseline",
|
||||
"description": "A device for testing out a widget",
|
||||
"readOnly": True,
|
||||
"softwareTrigger": False,
|
||||
"deviceTags": ["tag1", "tag2", "tag3"],
|
||||
"userParameter": {"some_setting": "some_ value"},
|
||||
}
|
||||
)
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -10,6 +10,7 @@ class ToggleSwitch(QWidget):
|
||||
A simple toggle.
|
||||
"""
|
||||
|
||||
stateChanged = Signal(bool)
|
||||
enabled = Signal(bool)
|
||||
ICON_NAME = "toggle_on"
|
||||
PLUGIN = True
|
||||
@@ -42,11 +43,19 @@ class ToggleSwitch(QWidget):
|
||||
|
||||
@checked.setter
|
||||
def checked(self, state):
|
||||
if self._checked != state:
|
||||
self.stateChanged.emit(state)
|
||||
self._checked = state
|
||||
self.update_colors()
|
||||
self.set_thumb_pos_to_state()
|
||||
self.enabled.emit(self._checked)
|
||||
|
||||
def setChecked(self, state: bool):
|
||||
self.checked = state
|
||||
|
||||
def isChecked(self):
|
||||
return self.checked
|
||||
|
||||
@Property(QPointF)
|
||||
def thumb_pos(self):
|
||||
return self._thumb_pos
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.13.0"
|
||||
version = "2.16.2"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
|
||||
@@ -51,7 +51,6 @@ def test_rpc_add_dock_with_plots_e2e(qtbot, bec_client_lib, connected_client_gui
|
||||
|
||||
# Waii until docks are registered
|
||||
qtbot.waitUntil(check_docks_registered, timeout=5000)
|
||||
qtbot.wait(500)
|
||||
assert len(dock.panels) == 3
|
||||
assert hasattr(gui.bec, "dock_0")
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_scan_metadata_for_custom_scan(
|
||||
def do_test():
|
||||
# Set the metadata
|
||||
grid: QGridLayout = scan_control._metadata_form._form_grid.layout()
|
||||
for i in range(grid.rowCount()): # type: ignore
|
||||
for i in range(grid.rowCount() - 1): # type: ignore
|
||||
field_name = grid.itemAtPosition(i, 0).widget().property("_model_field_name")
|
||||
if (value_to_set := md.pop(field_name, None)) is not None:
|
||||
grid.itemAtPosition(i, 1).widget().setValue(value_to_set)
|
||||
|
||||
@@ -168,11 +168,16 @@ def test_accept_changes(axis_settings_fixture, qtbot):
|
||||
axis_settings.ui.x_grid.checked = True
|
||||
|
||||
axis_settings.accept_changes()
|
||||
qtbot.wait(200)
|
||||
|
||||
assert plot_base.title == "New Title"
|
||||
assert plot_base.x_min == 10
|
||||
assert plot_base.x_max == 20
|
||||
assert plot_base.x_label == "New X Label"
|
||||
assert plot_base.x_log is True
|
||||
assert plot_base.x_grid is True
|
||||
qtbot.waitUntil(
|
||||
lambda: all(
|
||||
[
|
||||
plot_base.title == "New Title",
|
||||
plot_base.x_min == 10,
|
||||
plot_base.x_max == 20,
|
||||
plot_base.x_label == "New X Label",
|
||||
plot_base.x_log is True,
|
||||
plot_base.x_grid is True,
|
||||
]
|
||||
),
|
||||
timeout=200,
|
||||
)
|
||||
|
||||
@@ -47,24 +47,21 @@ def test_bec_dock_area_add_remove_dock(bec_dock_area, qtbot):
|
||||
# Remove docks
|
||||
d0_name = d0.name()
|
||||
bec_dock_area.delete(d0_name)
|
||||
qtbot.wait(200)
|
||||
d1.remove()
|
||||
qtbot.wait(200)
|
||||
|
||||
assert len(bec_dock_area.dock_area.docks) == initial_count + 1
|
||||
qtbot.waitUntil(lambda: len(bec_dock_area.dock_area.docks) == initial_count + 1, timeout=200)
|
||||
assert d0.name() not in dict(bec_dock_area.dock_area.docks)
|
||||
assert d1.name() not in dict(bec_dock_area.dock_area.docks)
|
||||
assert d2.name() in dict(bec_dock_area.dock_area.docks)
|
||||
|
||||
|
||||
def test_close_docks(bec_dock_area, qtbot):
|
||||
d0 = bec_dock_area.new(name="dock_0")
|
||||
d1 = bec_dock_area.new(name="dock_1")
|
||||
d2 = bec_dock_area.new(name="dock_2")
|
||||
_ = bec_dock_area.new(name="dock_0")
|
||||
_ = bec_dock_area.new(name="dock_1")
|
||||
_ = bec_dock_area.new(name="dock_2")
|
||||
|
||||
bec_dock_area.delete_all()
|
||||
qtbot.wait(200)
|
||||
assert len(bec_dock_area.dock_area.docks) == 0
|
||||
qtbot.waitUntil(lambda: len(bec_dock_area.dock_area.docks) == 0)
|
||||
|
||||
|
||||
def test_undock_and_dock_docks(bec_dock_area, qtbot):
|
||||
|
||||
@@ -5,7 +5,9 @@ import pytest
|
||||
from qtpy.QtCore import QPoint, Qt
|
||||
|
||||
from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_item import DeviceItemForm
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
|
||||
DeviceConfigForm,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
@@ -24,6 +26,7 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
@pytest.fixture
|
||||
def device_browser(qtbot, mocked_client):
|
||||
dev_browser = DeviceBrowser(client=mocked_client)
|
||||
dev_browser.dev["samx"].read_configuration = mock.MagicMock()
|
||||
qtbot.addWidget(dev_browser)
|
||||
qtbot.waitExposed(dev_browser)
|
||||
yield dev_browser
|
||||
@@ -84,10 +87,10 @@ def test_device_item_expansion(device_browser, qtbot):
|
||||
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
|
||||
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
|
||||
form = widget._contents.layout().itemAt(0).widget()
|
||||
qtbot.waitUntil(lambda: isinstance(form, DeviceItemForm), timeout=500)
|
||||
qtbot.waitUntil(lambda: isinstance(form, DeviceConfigForm), timeout=500)
|
||||
assert widget.expanded
|
||||
assert (name_field := form.widget_dict.get("name")) is not None
|
||||
assert name_field.getValue() == "samx"
|
||||
qtbot.waitUntil(lambda: name_field.getValue() == "samx", timeout=500)
|
||||
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
|
||||
assert not widget.expanded
|
||||
|
||||
|
||||
98
tests/unit_tests/test_device_config_form_dialog.py
Normal file
98
tests/unit_tests/test_device_config_form_dialog.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
DeviceConfigDialog,
|
||||
)
|
||||
|
||||
_BASIC_CONFIG = {
|
||||
"name": "test_device",
|
||||
"enabled": True,
|
||||
"deviceClass": "TestDevice",
|
||||
"readoutPriority": "monitored",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dialog(qtbot):
|
||||
"""Fixture to create a DeviceConfigDialog instance."""
|
||||
mock_device = MagicMock(_config=DeviceConfigModel.model_validate(_BASIC_CONFIG).model_dump())
|
||||
mock_client = MagicMock()
|
||||
mock_client.device_manager.devices = {"test_device": mock_device}
|
||||
dialog = DeviceConfigDialog(device="test_device", config_helper=MagicMock(), client=mock_client)
|
||||
qtbot.addWidget(dialog)
|
||||
return dialog
|
||||
|
||||
|
||||
def test_initialization(dialog):
|
||||
assert dialog._device == "test_device"
|
||||
assert dialog._container.count() == 2
|
||||
|
||||
|
||||
def test_fill_form(dialog):
|
||||
with patch.object(dialog._form, "set_data") as mock_set_data:
|
||||
dialog._fill_form()
|
||||
mock_set_data.assert_called_once_with(DeviceConfigModel.model_validate(_BASIC_CONFIG))
|
||||
|
||||
|
||||
def test_updated_config(dialog):
|
||||
"""Test that updated_config returns the correct changes."""
|
||||
dialog._initial_config = {"key1": "value1", "key2": "value2"}
|
||||
with patch.object(
|
||||
dialog._form, "get_form_data", return_value={"key1": "value1", "key2": "new_value"}
|
||||
):
|
||||
updated = dialog.updated_config()
|
||||
assert updated == {"key2": "new_value"}
|
||||
|
||||
|
||||
def test_apply(dialog):
|
||||
with patch.object(dialog, "_process_update_action") as mock_process_update:
|
||||
dialog.apply()
|
||||
mock_process_update.assert_called_once()
|
||||
|
||||
|
||||
def test_accept(dialog):
|
||||
with (
|
||||
patch.object(dialog, "_process_update_action") as mock_process_update,
|
||||
patch("qtpy.QtWidgets.QDialog.accept") as mock_parent_accept,
|
||||
):
|
||||
dialog.accept()
|
||||
mock_process_update.assert_called_once()
|
||||
mock_parent_accept.assert_called_once()
|
||||
|
||||
|
||||
def test_waiting_display(dialog, qtbot):
|
||||
with (
|
||||
patch.object(dialog._spinner, "start") as mock_spinner_start,
|
||||
patch.object(dialog._spinner, "stop") as mock_spinner_stop,
|
||||
):
|
||||
dialog.show()
|
||||
dialog._start_waiting_display()
|
||||
qtbot.waitUntil(dialog._overlay_widget.isVisible, timeout=100)
|
||||
mock_spinner_start.assert_called_once()
|
||||
mock_spinner_stop.assert_not_called()
|
||||
dialog._stop_waiting_display()
|
||||
qtbot.waitUntil(lambda: not dialog._overlay_widget.isVisible(), timeout=100)
|
||||
mock_spinner_stop.assert_called_once()
|
||||
|
||||
|
||||
def test_update_cycle(dialog, qtbot):
|
||||
update = {"enabled": False, "readoutPriority": "baseline", "deviceTags": {"tag"}}
|
||||
|
||||
def _mock_send(action="update", config=None, wait_for_response=True, timeout_s=None):
|
||||
dialog.client.device_manager.devices["test_device"]._config = config["test_device"] # type: ignore
|
||||
|
||||
dialog._config_helper.send_config_request = MagicMock(side_effect=_mock_send)
|
||||
for item in dialog._form.enumerate_form_widgets():
|
||||
if (val := update.get(item.label.property("_model_field_name"))) is not None:
|
||||
item.widget.setValue(val)
|
||||
|
||||
assert dialog.updated_config() == update
|
||||
dialog.apply()
|
||||
qtbot.waitUntil(lambda: dialog._config_helper.send_config_request.call_count == 1, timeout=100)
|
||||
|
||||
dialog._config_helper.send_config_request.assert_called_with(
|
||||
action="update", config={"test_device": update}, wait_for_response=False
|
||||
)
|
||||
@@ -3,12 +3,8 @@ from decimal import Decimal
|
||||
import pytest
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
|
||||
from bec_widgets.utils.forms_from_types.items import (
|
||||
FloatDecimalMetadataField,
|
||||
IntMetadataField,
|
||||
StrMetadataField,
|
||||
)
|
||||
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, TypedForm
|
||||
from bec_widgets.utils.forms_from_types.items import FloatDecimalFormItem, IntFormItem, StrFormItem
|
||||
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=missing-function-docstring
|
||||
@@ -58,9 +54,9 @@ def model_widget(qtbot):
|
||||
|
||||
|
||||
def test_widget_dict(model_widget: PydanticModelForm):
|
||||
assert isinstance(model_widget.widget_dict["str_optional"], StrMetadataField)
|
||||
assert isinstance(model_widget.widget_dict["float_nodefault"], FloatDecimalMetadataField)
|
||||
assert isinstance(model_widget.widget_dict["int_default"], IntMetadataField)
|
||||
assert isinstance(model_widget.widget_dict["str_optional"], StrFormItem)
|
||||
assert isinstance(model_widget.widget_dict["float_nodefault"], FloatDecimalFormItem)
|
||||
assert isinstance(model_widget.widget_dict["int_default"], IntFormItem)
|
||||
|
||||
|
||||
def test_widget_set_data(model_widget: PydanticModelForm):
|
||||
@@ -1,11 +1,12 @@
|
||||
import sys
|
||||
from typing import Literal
|
||||
from typing import Any, Literal, get_args
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
from bec_widgets.utils.forms_from_types.items import FormItemSpec
|
||||
from bec_widgets.utils.forms_from_types.items import FormItemSpec, ListFormItem
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 11), reason="Generic types don't support this in 3.10")
|
||||
@@ -58,3 +59,65 @@ def test_form_item_spec(input, validity):
|
||||
else:
|
||||
with pytest.raises(ValidationError):
|
||||
FormItemSpec.model_validate(input)
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
params=[
|
||||
{"type": list[int], "value": [1, 2, 3], "extra": 79},
|
||||
{"type": list[str], "value": ["a", "b", "c"], "extra": "string"},
|
||||
{"type": list[float], "value": [0.1, 0.2, 0.3], "extra": 79.0},
|
||||
]
|
||||
)
|
||||
def list_field_and_values(request, qtbot):
|
||||
itype, vals, extra = (
|
||||
request.param.get("type"),
|
||||
request.param.get("value"),
|
||||
request.param.get("extra"),
|
||||
)
|
||||
spec = FormItemSpec(item_type=itype, name="test_list", info=FieldInfo(annotation=itype))
|
||||
(widget := ListFormItem(parent=None, spec=spec)).setValue(vals)
|
||||
qtbot.addWidget(widget)
|
||||
yield widget, vals, extra, get_args(itype)[0]
|
||||
|
||||
|
||||
def test_list_metadata_field(list_field_and_values: tuple[ListFormItem, list, Any, type]):
|
||||
list_field, vals, extra, _ = list_field_and_values
|
||||
assert list_field.getValue() == vals
|
||||
assert list_field._main_widget.count() == 3
|
||||
|
||||
list_field._add_button.click()
|
||||
assert len(list_field.getValue()) == 4
|
||||
assert list_field._main_widget.count() == 4
|
||||
|
||||
list_field._main_widget.setCurrentRow(-1)
|
||||
list_field._remove_button.click()
|
||||
assert len(list_field.getValue()) == 4
|
||||
assert list_field._main_widget.count() == 4
|
||||
|
||||
list_field._main_widget.setCurrentRow(2)
|
||||
list_field._remove_button.click()
|
||||
assert list_field.getValue() == vals[:2] + [list_field._types.default]
|
||||
assert list_field._main_widget.count() == 3
|
||||
|
||||
list_field._main_widget.setCurrentRow(1)
|
||||
WidgetIO.set_value(list_field._main_widget.itemWidget(list_field._main_widget.item(1)), extra)
|
||||
assert list_field._main_widget.count() == 3
|
||||
assert list_field.getValue() == [vals[0], extra, list_field._types.default]
|
||||
|
||||
list_field._add_data_item(extra)
|
||||
assert list_field._main_widget.count() == 4
|
||||
assert list_field.getValue() == [vals[0], extra, list_field._types.default, extra]
|
||||
|
||||
|
||||
def test_list_field_value_acceptance(list_field_and_values: tuple[ListFormItem, list, Any, type]):
|
||||
class _WrongType(object): ...
|
||||
|
||||
list_field, _, _, t = list_field_and_values
|
||||
list_field.setValue([])
|
||||
assert list_field._main_widget.count() == 0
|
||||
list_field.setValue([t(), t(), t()])
|
||||
assert list_field._main_widget.count() == 3
|
||||
with pytest.raises(ValueError) as e:
|
||||
list_field.setValue([_WrongType()])
|
||||
assert list_field._main_widget.count() == 3
|
||||
assert e.match(f"This widget only accepts items of type {t}")
|
||||
|
||||
@@ -120,11 +120,11 @@ def test_roi_name_edit(roi_tree, image_widget, qtbot):
|
||||
roi_tree.tree.editItem(item, roi_tree.COL_ROI)
|
||||
qtbot.keyClicks(roi_tree.tree.viewport().focusWidget(), "new_name")
|
||||
qtbot.keyClick(roi_tree.tree.viewport().focusWidget(), Qt.Key_Return)
|
||||
qtbot.wait(200)
|
||||
|
||||
# Check the ROI name was updated
|
||||
assert roi.label == "new_name"
|
||||
assert item.text(roi_tree.COL_ROI) == "new_name"
|
||||
qtbot.waitUntil(
|
||||
lambda: all([roi.label == "new_name", item.text(roi_tree.COL_ROI) == "new_name"]),
|
||||
timeout=200,
|
||||
)
|
||||
|
||||
|
||||
def test_roi_width_edit(roi_tree, image_widget, qtbot):
|
||||
@@ -138,9 +138,8 @@ def test_roi_width_edit(roi_tree, image_widget, qtbot):
|
||||
|
||||
# Change the width
|
||||
width_spin.setValue(25)
|
||||
qtbot.wait(200)
|
||||
# Check the ROI width was updated
|
||||
assert roi.line_width == 25
|
||||
qtbot.waitUntil(lambda: roi.line_width == 25, timeout=200)
|
||||
|
||||
|
||||
def test_delete_roi_button(roi_tree, image_widget, qtbot):
|
||||
@@ -153,11 +152,12 @@ def test_delete_roi_button(roi_tree, image_widget, qtbot):
|
||||
|
||||
del_btn = layout.itemAt(1).widget()
|
||||
del_btn.click()
|
||||
qtbot.wait(200)
|
||||
|
||||
# Verify ROI was removed
|
||||
assert roi not in roi_tree.roi_items
|
||||
assert roi not in image_widget.roi_controller.rois
|
||||
qtbot.waitUntil(
|
||||
lambda: all([roi not in roi_tree.roi_items, roi not in image_widget.roi_controller.rois]),
|
||||
timeout=200,
|
||||
)
|
||||
|
||||
|
||||
def test_roi_color_change_from_roi(roi_tree, image_widget):
|
||||
|
||||
@@ -6,16 +6,21 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI, ROIController
|
||||
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||
CircularROI,
|
||||
EllipticalROI,
|
||||
RectangularROI,
|
||||
ROIController,
|
||||
)
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
|
||||
@pytest.fixture(params=["rect", "circle"])
|
||||
@pytest.fixture(params=["rect", "circle", "ellipse"])
|
||||
def bec_image_widget_with_roi(qtbot, request, mocked_client):
|
||||
"""Return (widget, roi, shape_label) for each ROI class."""
|
||||
|
||||
roi_type: Literal["rect", "circle"] = request.param
|
||||
roi_type: Literal["rect", "circle", "ellipse"] = request.param
|
||||
|
||||
# Build an Image widget with a trivial 100×100 zeros array
|
||||
widget: Image = create_widget(qtbot, Image, client=mocked_client)
|
||||
@@ -39,7 +44,12 @@ def test_default_properties(bec_image_widget_with_roi):
|
||||
assert roi.line_width == 5
|
||||
|
||||
# concrete subclass type
|
||||
assert isinstance(roi, RectangularROI) if roi_type == "rect" else isinstance(roi, CircularROI)
|
||||
if roi_type == "rect":
|
||||
assert isinstance(roi, RectangularROI)
|
||||
elif roi_type == "circle":
|
||||
assert isinstance(roi, CircularROI)
|
||||
elif roi_type == "ellipse":
|
||||
assert isinstance(roi, EllipticalROI)
|
||||
|
||||
|
||||
def test_coordinate_structures(bec_image_widget_with_roi):
|
||||
@@ -98,7 +108,7 @@ def test_color_uniqueness_across_multiple_rois(qtbot, mocked_client):
|
||||
widget: Image = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
# add two of each ROI type
|
||||
for _kind in ("rect", "circle"):
|
||||
for _kind in ("rect", "circle", "ellipse"):
|
||||
widget.add_roi(kind=_kind)
|
||||
widget.add_roi(kind=_kind)
|
||||
|
||||
|
||||
@@ -508,19 +508,31 @@ def test_crosshair_roi_panels_visibility(qtbot, mocked_client):
|
||||
|
||||
# Enable ROI crosshair
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(500)
|
||||
|
||||
# Panels must be visible
|
||||
assert bec_image_view.side_panel_x.panel_height > 0
|
||||
assert bec_image_view.side_panel_y.panel_width > 0
|
||||
qtbot.waitUntil(
|
||||
lambda: all(
|
||||
[
|
||||
bec_image_view.side_panel_x.panel_height > 0,
|
||||
bec_image_view.side_panel_y.panel_width > 0,
|
||||
]
|
||||
),
|
||||
timeout=500,
|
||||
)
|
||||
|
||||
# Disable ROI crosshair
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(500)
|
||||
|
||||
# Panels hidden again
|
||||
assert bec_image_view.side_panel_x.panel_height == 0
|
||||
assert bec_image_view.side_panel_y.panel_width == 0
|
||||
qtbot.waitUntil(
|
||||
lambda: all(
|
||||
[
|
||||
bec_image_view.side_panel_x.panel_height == 0,
|
||||
bec_image_view.side_panel_y.panel_width == 0,
|
||||
]
|
||||
),
|
||||
timeout=500,
|
||||
)
|
||||
|
||||
|
||||
def test_roi_plot_data_from_image(qtbot, mocked_client):
|
||||
|
||||
189
tests/unit_tests/test_main_widnow.py
Normal file
189
tests/unit_tests/test_main_widnow.py
Normal file
@@ -0,0 +1,189 @@
|
||||
import webbrowser
|
||||
|
||||
import pytest
|
||||
from qtpy.QtWidgets import QFrame
|
||||
|
||||
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
|
||||
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bec_main_window(qtbot, mocked_client):
|
||||
widget = BECMainWindow(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
#################################################################
|
||||
# Tests for BECMainWindow Initialization and Functionality
|
||||
#################################################################
|
||||
|
||||
|
||||
def test_bec_main_window_initialization(bec_main_window):
|
||||
assert isinstance(bec_main_window, BECMainWindow)
|
||||
assert bec_main_window.windowTitle() == "BEC"
|
||||
assert bec_main_window.app is not None
|
||||
assert bec_main_window.statusBar() is not None
|
||||
assert bec_main_window._app_id_label is not None
|
||||
|
||||
|
||||
def test_bec_main_window_display_client_message(qtbot, bec_main_window):
|
||||
"""
|
||||
Verify that display_client_message updates the client‑info label.
|
||||
"""
|
||||
test_msg = "Client connected successfully"
|
||||
bec_main_window.display_client_message({"message": test_msg}, {})
|
||||
qtbot.wait(200)
|
||||
assert bec_main_window._client_info_label.text() == test_msg
|
||||
|
||||
|
||||
def test_status_bar_has_separator(bec_main_window):
|
||||
"""Ensure the status bar contains at least one vertical separator."""
|
||||
status_bar = bec_main_window.statusBar()
|
||||
separators = [w for w in status_bar.findChildren(QFrame) if w.frameShape() == QFrame.VLine]
|
||||
assert separators, "Expected at least one QFrame separator in the status bar."
|
||||
|
||||
|
||||
#################################################################
|
||||
# Tests for BECMainWindow Addons
|
||||
#################################################################
|
||||
|
||||
#################################################################
|
||||
# Tests for ScrollLabel behaviour
|
||||
|
||||
|
||||
def test_scroll_label_does_not_scroll_when_text_fits(qtbot):
|
||||
"""Label with short text should not activate scrolling timer."""
|
||||
lbl = create_widget(qtbot, ScrollLabel) # shorten delay for test speed
|
||||
qtbot.addWidget(lbl)
|
||||
lbl.resize(200, 20)
|
||||
lbl.setText("Short text")
|
||||
# Process events to allow timer logic to run
|
||||
qtbot.wait(200)
|
||||
assert not lbl._timer.isActive()
|
||||
assert not lbl._delay_timer.isActive()
|
||||
|
||||
|
||||
def test_scroll_label_starts_scrolling(qtbot):
|
||||
"""Label with long text should start _delay_timer; later _timer becomes active."""
|
||||
lbl = create_widget(qtbot, ScrollLabel, delay_ms=100)
|
||||
lbl.resize(150, 20)
|
||||
long_text = "This is a very long piece of text that should definitely overflow the label width"
|
||||
lbl.setText(long_text)
|
||||
# Immediately after setText, only delay‑timer should be active
|
||||
assert lbl._delay_timer.isActive()
|
||||
assert not lbl._timer.isActive()
|
||||
# Wait until scrolling timer becomes active
|
||||
qtbot.waitUntil(lambda: lbl._timer.isActive(), timeout=2000)
|
||||
assert lbl._timer.isActive()
|
||||
|
||||
|
||||
def test_scroll_label_scroll_method(qtbot):
|
||||
"""Directly exercise _scroll to ensure offset advances and paintEvent is invoked."""
|
||||
lbl = create_widget(qtbot, ScrollLabel, step_px=5) # shorten delay for test speed
|
||||
qtbot.addWidget(lbl)
|
||||
lbl.resize(120, 20)
|
||||
lbl.setText("x" * 200) # long text to guarantee overflow
|
||||
qtbot.wait(200) # let timers configure themselves
|
||||
|
||||
# Capture current offset and force a manual scroll tick
|
||||
old_offset = lbl._offset
|
||||
lbl._scroll()
|
||||
assert lbl._offset == old_offset + 5
|
||||
|
||||
|
||||
def test_scroll_label_paint_event(qtbot):
|
||||
"""
|
||||
Grab the widget as a pixmap; this calls paintEvent under the hood
|
||||
and ensures no exceptions occur during rendering.
|
||||
"""
|
||||
lbl = create_widget(qtbot, ScrollLabel) # shorten delay for test speed
|
||||
qtbot.addWidget(lbl)
|
||||
lbl.resize(180, 20)
|
||||
lbl.setText("Rendering check")
|
||||
lbl.show()
|
||||
qtbot.wait(200) # allow Qt to schedule a paint
|
||||
pixmap = lbl.grab()
|
||||
assert not pixmap.isNull()
|
||||
|
||||
|
||||
def test_display_client_message_with_expiration(qtbot, bec_main_window):
|
||||
"""
|
||||
A message with a finite 'expire' value should disappear once the timer
|
||||
fires.
|
||||
"""
|
||||
test_msg = "This message should vanish fast"
|
||||
expire_sec = 0.2
|
||||
|
||||
bec_main_window.display_client_message({"message": test_msg, "expire": expire_sec}, {})
|
||||
|
||||
assert bec_main_window._client_info_expire_timer.isActive()
|
||||
assert bec_main_window._client_info_label.text() == test_msg
|
||||
|
||||
qtbot.waitUntil(lambda: not bec_main_window._client_info_expire_timer.isActive(), timeout=1000)
|
||||
|
||||
assert bec_main_window._client_info_label.text() == ""
|
||||
|
||||
|
||||
def test_display_client_message_no_expiration(qtbot, bec_main_window):
|
||||
"""
|
||||
A message with 'expire' == 0 must persist and never start the timer.
|
||||
"""
|
||||
test_msg = "Persistent status message"
|
||||
|
||||
bec_main_window.display_client_message({"message": test_msg, "expire": 0}, {})
|
||||
|
||||
assert not bec_main_window._client_info_expire_timer.isActive()
|
||||
assert bec_main_window._client_info_label.text() == test_msg
|
||||
|
||||
qtbot.wait(500)
|
||||
assert bec_main_window._client_info_label.text() == test_msg
|
||||
|
||||
|
||||
def test_display_client_message_overwrite_resets_timer(qtbot, bec_main_window):
|
||||
"""
|
||||
Sending a second message while the expiration timer is active should
|
||||
overwrite the first and stop the timer if the second one is persistent.
|
||||
"""
|
||||
first_msg = "First (temporary)"
|
||||
second_msg = "Second (persistent)"
|
||||
|
||||
bec_main_window.display_client_message({"message": first_msg, "expire": 0.3}, {})
|
||||
qtbot.wait(200)
|
||||
assert bec_main_window._client_info_expire_timer.isActive()
|
||||
|
||||
bec_main_window.display_client_message({"message": second_msg, "expire": 0}, {})
|
||||
|
||||
assert not bec_main_window._client_info_expire_timer.isActive()
|
||||
assert bec_main_window._client_info_label.text() == second_msg
|
||||
|
||||
qtbot.wait(400)
|
||||
assert bec_main_window._client_info_label.text() == second_msg
|
||||
|
||||
|
||||
#################################################################
|
||||
# Tests for BECWebLinksMixin (webbrowser opening)
|
||||
|
||||
|
||||
def test_bec_weblinks(monkeypatch):
|
||||
opened_urls = []
|
||||
|
||||
def fake_open(url):
|
||||
opened_urls.append(url)
|
||||
|
||||
monkeypatch.setattr(webbrowser, "open", fake_open)
|
||||
|
||||
BECWebLinksMixin.open_bec_docs()
|
||||
BECWebLinksMixin.open_bec_widgets_docs()
|
||||
BECWebLinksMixin.open_bec_bug_report()
|
||||
|
||||
assert opened_urls == [
|
||||
"https://beamline-experiment-control.readthedocs.io/en/latest/",
|
||||
"https://bec.readthedocs.io/projects/bec-widgets/en/latest/",
|
||||
"https://gitlab.psi.ch/groups/bec/-/issues/",
|
||||
]
|
||||
@@ -7,7 +7,7 @@ from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.messages import AvailableResourceMessage, ScanQueueHistoryMessage, ScanQueueMessage
|
||||
from qtpy.QtCore import QModelIndex, Qt
|
||||
|
||||
from bec_widgets.utils.forms_from_types.items import StrMetadataField
|
||||
from bec_widgets.utils.forms_from_types.items import StrFormItem
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
|
||||
@@ -570,7 +570,7 @@ def test_scan_metadata_is_connected(scan_control):
|
||||
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
|
||||
assert scan_control._metadata_form._scan_name == "grid_scan"
|
||||
sample_name = scan_control._metadata_form._form_grid.layout().itemAtPosition(0, 1).widget()
|
||||
assert isinstance(sample_name, StrMetadataField)
|
||||
assert isinstance(sample_name, StrFormItem)
|
||||
sample_name._main_widget.setText("Test Sample")
|
||||
|
||||
scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY
|
||||
|
||||
@@ -8,12 +8,12 @@ from pydantic.types import Json
|
||||
from qtpy.QtCore import QItemSelectionModel, QPoint, Qt
|
||||
|
||||
from bec_widgets.utils.forms_from_types.items import (
|
||||
BoolMetadataField,
|
||||
DictMetadataField,
|
||||
BoolFormItem,
|
||||
DictFormItem,
|
||||
DynamicFormItem,
|
||||
FloatDecimalMetadataField,
|
||||
IntMetadataField,
|
||||
StrMetadataField,
|
||||
FloatDecimalFormItem,
|
||||
IntFormItem,
|
||||
StrFormItem,
|
||||
)
|
||||
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
|
||||
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
|
||||
@@ -42,7 +42,7 @@ class ExampleSchema(BasicScanMetadata):
|
||||
|
||||
TEST_DICT = {
|
||||
"sample_name": "test name",
|
||||
"str_optional": "None",
|
||||
"str_optional": None,
|
||||
"str_required": "something",
|
||||
"bool_optional": None,
|
||||
"bool_required_default": True,
|
||||
@@ -125,18 +125,18 @@ def test_griditems_are_correct_class(
|
||||
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
|
||||
):
|
||||
_, components = metadata_widget
|
||||
assert isinstance(components["sample_name"], StrMetadataField)
|
||||
assert isinstance(components["str_optional"], StrMetadataField)
|
||||
assert isinstance(components["str_required"], StrMetadataField)
|
||||
assert isinstance(components["bool_optional"], BoolMetadataField)
|
||||
assert isinstance(components["bool_required_default"], BoolMetadataField)
|
||||
assert isinstance(components["bool_required_nodefault"], BoolMetadataField)
|
||||
assert isinstance(components["int_default"], IntMetadataField)
|
||||
assert isinstance(components["int_nodefault_optional"], IntMetadataField)
|
||||
assert isinstance(components["float_nodefault"], FloatDecimalMetadataField)
|
||||
assert isinstance(components["decimal_dp_limits_nodefault"], FloatDecimalMetadataField)
|
||||
assert isinstance(components["dict_default"], DictMetadataField)
|
||||
assert isinstance(components["unsupported_class"], StrMetadataField)
|
||||
assert isinstance(components["sample_name"], StrFormItem)
|
||||
assert isinstance(components["str_optional"], StrFormItem)
|
||||
assert isinstance(components["str_required"], StrFormItem)
|
||||
assert isinstance(components["bool_optional"], BoolFormItem)
|
||||
assert isinstance(components["bool_required_default"], BoolFormItem)
|
||||
assert isinstance(components["bool_required_nodefault"], BoolFormItem)
|
||||
assert isinstance(components["int_default"], IntFormItem)
|
||||
assert isinstance(components["int_nodefault_optional"], IntFormItem)
|
||||
assert isinstance(components["float_nodefault"], FloatDecimalFormItem)
|
||||
assert isinstance(components["decimal_dp_limits_nodefault"], FloatDecimalFormItem)
|
||||
assert isinstance(components["dict_default"], DictFormItem)
|
||||
assert isinstance(components["unsupported_class"], StrFormItem)
|
||||
|
||||
|
||||
def test_grid_to_dict(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]):
|
||||
|
||||
@@ -23,4 +23,7 @@ def test_website_widget_set_url(website_widget):
|
||||
|
||||
website_widget.set_url("https://google.com")
|
||||
website_widget.wait_until_loaded()
|
||||
assert website_widget.get_url() == "https://www.google.com/"
|
||||
# in case we get https://www.google.com/sorry/index?continue=https://google.com/&q=...
|
||||
# because of rate limiting or ddos protections etc
|
||||
# e.g. https://github.com/bec-project/bec_widgets/actions/runs/15675153971/job/44172519713?pr=686
|
||||
assert website_widget.get_url().startswith("https://www.google.com/")
|
||||
|
||||
Reference in New Issue
Block a user