1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-16 13:38:51 +02:00

Compare commits

...

43 Commits

Author SHA1 Message Date
semantic-release
0ae4b652a4 2.13.2
Automatically generated by python-semantic-release
2025-06-13 16:17:37 +00:00
32fd959e67 fix: allow sets in generated form types 2025-06-13 18:16:56 +02:00
semantic-release
73b1886bb8 2.13.1
Automatically generated by python-semantic-release
2025-06-12 12:51:59 +00:00
9f853b0864 fix(main_window): event filter applied on QEvent.Type.StatusTip; closes #698 2025-06-12 14:51:14 +02:00
semantic-release
18636e723a 2.13.0
Automatically generated by python-semantic-release
2025-06-10 15:18:29 +00:00
594185dde9 feat(image_roi_tree): lock/unlock rois possible through the ROIPropertyTree 2025-06-10 17:17:31 +02:00
46d7e3f517 feat(roi): rois can be lock to be not moved by mouse 2025-06-10 17:17:31 +02:00
f9044996f6 fix(roi): removed roi handle adding/removing inconsistencies 2025-06-10 17:17:31 +02:00
semantic-release
03474cf7f7 2.12.4
Automatically generated by python-semantic-release
2025-06-10 14:42:40 +00:00
9ef418bf55 fix(image_roi): coordinates are emitted correctly when handles are inverted; closes #672 2025-06-10 16:41:59 +02:00
b3ce68070d ci: add stale issue job 2025-06-06 14:48:10 +02:00
semantic-release
784b54af6e 2.12.3
Automatically generated by python-semantic-release
2025-06-05 19:07:20 +00:00
3740ac8e32 build: update min dependency of bec to 3.38 2025-06-05 21:06:32 +02:00
edfac87868 fix(crosshair): use objectName instead of config for retrieving the monitor name 2025-06-05 21:06:32 +02:00
271116453d fix(image): preview signals can be used in Image widget; update logic adjusted; closes #683 2025-06-05 21:06:32 +02:00
12f5233745 fix(device_combobox): tuple entries of preview signals are checked in DeviceComboBoxes just for the relevant device 2025-06-05 21:06:32 +02:00
semantic-release
392ddf9d1a 2.12.2
Automatically generated by python-semantic-release
2025-06-05 13:27:05 +00:00
85705383e4 fix(waveform): safeguard for history data access, closes #571; removed return values "none" 2025-06-05 15:26:19 +02:00
semantic-release
224863569f 2.12.1
Automatically generated by python-semantic-release
2025-06-05 12:07:35 +00:00
3e2544e52a fix(crosshair): emitted name from crosshair 2D is objectName of image or its id 2025-06-05 14:04:44 +02:00
semantic-release
4d5daf6557 2.12.0
Automatically generated by python-semantic-release
2025-06-04 19:51:34 +00:00
718116afc3 fix: exclude metadata from RPC 2025-06-04 21:50:54 +02:00
2dda58f7d2 feat: add clickable label util 2025-06-04 21:50:54 +02:00
594912136e fix: grid formatting in TypedForm 2025-06-04 21:50:54 +02:00
5188b38c86 feat: (#493) device browser to display config 2025-06-04 21:50:54 +02:00
a10e6f7820 fix: make generate plugin robust to multiline init
instead of str.find, use multiline regex with whitespace
2025-06-04 21:50:54 +02:00
e0e26c205b fix(device browser): mocks and utils for tests 2025-06-04 21:50:54 +02:00
92d1d6435d feat: (#493) add dict to dynamic form types 2025-06-04 21:50:54 +02:00
a25c1a8039 feat: (#493) add helpers to dynamic form widgets 2025-06-04 21:50:54 +02:00
semantic-release
fed068f857 2.11.0
Automatically generated by python-semantic-release
2025-06-04 12:12:27 +00:00
7eb2f54e0e fix(image layer): add layer main if it does not exist 2025-06-04 14:11:46 +02:00
92b89e7275 refactor(image_base): move default color map to image layer 2025-06-04 14:11:46 +02:00
a4f3117941 refactor(image_item): emit object name with removed signal 2025-06-04 14:11:46 +02:00
3e789ca35b refactor(image_item): removed outdated image item config 2025-06-04 14:11:46 +02:00
92dade0950 refactor(image_base): renamed layers to layer_manager and added public methods for accessing the layer manager 2025-06-04 14:11:46 +02:00
4a343b2041 feat(image_layer): add default name for image layers 2025-06-04 14:11:46 +02:00
c2b0c8c433 refactor(image): move image item creation to layer manager 2025-06-04 14:11:46 +02:00
8a299a8268 refactor(image): disconnect when layer is removed 2025-06-04 14:11:46 +02:00
99ecf6a18f refactor(image): removed access to image item config 2025-06-04 14:11:46 +02:00
4c0bd977fc fix(image_item): do not disconnect the monitor from within the image item 2025-06-04 14:11:46 +02:00
7c47505c5a test: improve error message for widgets that are not properly cleaned up 2025-06-04 14:11:46 +02:00
e211e4d716 fix(image item): propagate remove call to parent class 2025-06-04 14:11:46 +02:00
10f292def9 refactor(image): introduce image base and image layer; rename vrange to v_range 2025-06-04 14:11:46 +02:00
40 changed files with 2933 additions and 1162 deletions

15
.github/workflows/stale-issues.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
name: 'Close stale issues and PRs'
on:
schedule:
- cron: '00 10 * * *'
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
days-before-stale: 60
days-before-close: 7

View File

@@ -1,6 +1,171 @@
# CHANGELOG
## 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
- **roi**: Removed roi handle adding/removing inconsistencies
([`f904499`](https://github.com/bec-project/bec_widgets/commit/f9044996f6d62cdbb693149934b09625fb39fd55))
### Features
- **image_roi_tree**: Lock/unlock rois possible through the ROIPropertyTree
([`594185d`](https://github.com/bec-project/bec_widgets/commit/594185dde9c73991489f2154507f1c3d3822c5b4))
- **roi**: Rois can be lock to be not moved by mouse
([`46d7e3f`](https://github.com/bec-project/bec_widgets/commit/46d7e3f5170a5f8b444043bc49651921816f7003))
## v2.12.4 (2025-06-10)
### Bug Fixes
- **image_roi**: Coordinates are emitted correctly when handles are inverted; closes #672
([`9ef418b`](https://github.com/bec-project/bec_widgets/commit/9ef418bf5597d4be77adc3c0c88c1c1619c9aa2f))
### Continuous Integration
- Add stale issue job
([`b3ce680`](https://github.com/bec-project/bec_widgets/commit/b3ce68070d58cdd76559cbd7db04cdbcc6c1f075))
## v2.12.3 (2025-06-05)
### Bug Fixes
- **crosshair**: Use objectName instead of config for retrieving the monitor name
([`edfac87`](https://github.com/bec-project/bec_widgets/commit/edfac87868605b4b755f7732b2841673de53bc3f))
- **device_combobox**: Tuple entries of preview signals are checked in DeviceComboBoxes just for the
relevant device
([`12f5233`](https://github.com/bec-project/bec_widgets/commit/12f523374586d55499f80baf56a50b6ef486cd43))
- **image**: Preview signals can be used in Image widget; update logic adjusted; closes #683
([`2711164`](https://github.com/bec-project/bec_widgets/commit/271116453d1ef5316b19457d04613b6ddc939cdb))
### Build System
- Update min dependency of bec to 3.38
([`3740ac8`](https://github.com/bec-project/bec_widgets/commit/3740ac8e325a489d59faca648896ffcea29e1a02))
## v2.12.2 (2025-06-05)
### Bug Fixes
- **waveform**: Safeguard for history data access, closes #571; removed return values "none"
([`8570538`](https://github.com/bec-project/bec_widgets/commit/85705383e4aff2f83f76d342db0a13380aeca42f))
## v2.12.1 (2025-06-05)
### Bug Fixes
- **crosshair**: Emitted name from crosshair 2D is objectName of image or its id
([`3e2544e`](https://github.com/bec-project/bec_widgets/commit/3e2544e52a84b30a5acb4a7874025fa359a3c58d))
## v2.12.0 (2025-06-04)
### Bug Fixes
- Exclude metadata from RPC
([`718116a`](https://github.com/bec-project/bec_widgets/commit/718116afc3a724658c4cd57b76e93249a66a9ebd))
- Grid formatting in TypedForm
([`5949121`](https://github.com/bec-project/bec_widgets/commit/594912136e2118de1a4de5213c2f668952f28a84))
- Make generate plugin robust to multiline init
([`a10e6f7`](https://github.com/bec-project/bec_widgets/commit/a10e6f7820309d590e832f2bca44ca1db8ef72a1))
instead of str.find, use multiline regex with whitespace
- **device browser**: Mocks and utils for tests
([`e0e26c2`](https://github.com/bec-project/bec_widgets/commit/e0e26c205bf930d680e01910f87489decc7fbcdb))
### Features
- (#493) add dict to dynamic form types
([`92d1d64`](https://github.com/bec-project/bec_widgets/commit/92d1d6435d6e8c05851804eb76605a4abeec01bb))
- (#493) add helpers to dynamic form widgets
([`a25c1a8`](https://github.com/bec-project/bec_widgets/commit/a25c1a8039078c92789b717b3f8a553c75814c33))
- (#493) device browser to display config
([`5188b38`](https://github.com/bec-project/bec_widgets/commit/5188b38c86f543d2abc742411b64fa127c6c0c16))
- Add clickable label util
([`2dda58f`](https://github.com/bec-project/bec_widgets/commit/2dda58f7d2adf1f41c6ce4fad02d55bd9aa200fa))
## v2.11.0 (2025-06-04)
### Bug Fixes
- **image item**: Propagate remove call to parent class
([`e211e4d`](https://github.com/bec-project/bec_widgets/commit/e211e4d7161cc4fc4b2f7cd18f058e070f5b4b7a))
- **image layer**: Add layer main if it does not exist
([`7eb2f54`](https://github.com/bec-project/bec_widgets/commit/7eb2f54e0ed556e0c30a4e14ded75e32dcf3d531))
- **image_item**: Do not disconnect the monitor from within the image item
([`4c0bd97`](https://github.com/bec-project/bec_widgets/commit/4c0bd977fc2b82680bbace763f5ffb19ed664f72))
### Features
- **image_layer**: Add default name for image layers
([`4a343b2`](https://github.com/bec-project/bec_widgets/commit/4a343b204112c53e593e9bb43642d21f268dfa85))
### Refactoring
- **image**: Disconnect when layer is removed
([`8a299a8`](https://github.com/bec-project/bec_widgets/commit/8a299a8268f3c21bbdc6629ad1f1f50a0aa0948b))
- **image**: Introduce image base and image layer; rename vrange to v_range
([`10f292d`](https://github.com/bec-project/bec_widgets/commit/10f292def9d1551bca0d8f63c0a94799c08ff507))
- **image**: Move image item creation to layer manager
([`c2b0c8c`](https://github.com/bec-project/bec_widgets/commit/c2b0c8c4336302ec4a7807c31b3f3b78a413c1aa))
- **image**: Removed access to image item config
([`99ecf6a`](https://github.com/bec-project/bec_widgets/commit/99ecf6a18f2e87d68f3de3abf56d97f7e6467912))
- **image_base**: Move default color map to image layer
([`92b89e7`](https://github.com/bec-project/bec_widgets/commit/92b89e72750fc0ab72ea51f865032133c49a7f18))
- **image_base**: Renamed layers to layer_manager and added public methods for accessing the layer
manager
([`92dade0`](https://github.com/bec-project/bec_widgets/commit/92dade09508ff3940e0b5dc99917302d61b03bc8))
- **image_item**: Emit object name with removed signal
([`a4f3117`](https://github.com/bec-project/bec_widgets/commit/a4f311794132c6c24370cb2f5b5e0725b12587fd))
- **image_item**: Removed outdated image item config
([`3e789ca`](https://github.com/bec-project/bec_widgets/commit/3e789ca35b6d0cf2d8ae9677bc65b7f0ca4eabc7))
### Testing
- Improve error message for widgets that are not properly cleaned up
([`7c47505`](https://github.com/bec-project/bec_widgets/commit/7c47505c5a147885ca2e854e13c1eb3fddaf5489))
## v2.10.3 (2025-06-04)
### Bug Fixes

View File

@@ -530,6 +530,26 @@ class BaseROI(RPCBase):
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":
@@ -639,6 +659,26 @@ class CircularROI(RPCBase):
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":
@@ -1252,16 +1292,16 @@ class Image(RPCBase):
@property
@rpc_call
def vrange(self) -> "tuple":
def v_range(self) -> "QPointF":
"""
Get the vrange of the image.
Set the v_range of the main image.
"""
@vrange.setter
@v_range.setter
@rpc_call
def vrange(self) -> "tuple":
def v_range(self) -> "QPointF":
"""
Get the vrange of the image.
Set the v_range of the main image.
"""
@property
@@ -1459,12 +1499,12 @@ class Image(RPCBase):
@rpc_call
def image(
self,
monitor: "str | None" = None,
monitor: "str | tuple | None" = None,
monitor_type: "Literal['auto', '1d', '2d']" = "auto",
color_map: "str | None" = None,
color_bar: "Literal['simple', 'full'] | None" = None,
vrange: "tuple[int, int] | None" = None,
) -> "ImageItem":
) -> "ImageItem | None":
"""
Set the image source and update the image.
@@ -1494,6 +1534,7 @@ class Image(RPCBase):
line_width: "int | None" = 5,
pos: "tuple[float, float] | None" = (10, 10),
size: "tuple[float, float] | None" = (50, 50),
movable: "bool" = True,
**pg_kwargs,
) -> "RectangularROI | CircularROI":
"""
@@ -1505,6 +1546,7 @@ class Image(RPCBase):
line_width(int): The line width of the ROI.
pos(tuple): The position of the ROI.
size(tuple): The size of the ROI.
movable(bool): Whether the ROI is movable.
**pg_kwargs: Additional arguments for the ROI.
Returns:
@@ -2664,6 +2706,26 @@ class RectangularROI(RPCBase):
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":

View File

@@ -184,8 +184,8 @@ class FakePositioner(BECPositioner):
class Positioner(FakePositioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name="test", limits=None, read_value=1.0):
super().__init__(name, limits, read_value)
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
class Device(FakeDevice):

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
from qtpy.QtCore import Signal
from qtpy.QtGui import QMouseEvent
from qtpy.QtWidgets import QLabel
class ClickableLabel(QLabel):
clicked = Signal()
def mouseReleaseEvent(self, ev: QMouseEvent) -> None:
self.clicked.emit()
return super().mouseReleaseEvent(ev)

View File

@@ -15,12 +15,15 @@ if TYPE_CHECKING: # pragma: no cover
from bec_qthemes._main import AccentColors
def get_theme_palette():
def get_theme_name():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
theme = "dark"
return "dark"
else:
theme = QApplication.instance().theme.theme
return bec_qthemes.load_palette(theme)
return QApplication.instance().theme.theme
def get_theme_palette():
return bec_qthemes.load_palette(get_theme_name())
def get_accent_colors() -> AccentColors | None:

View File

@@ -312,7 +312,7 @@ class Crosshair(QObject):
y_values[name] = closest_y
x_values[name] = closest_x
elif isinstance(item, pg.ImageItem): # 2D plot
name = item.config.monitor or str(id(item))
name = item.objectName() or str(id(item))
image_2d = item.image
if image_2d is None:
continue
@@ -400,7 +400,7 @@ class Crosshair(QObject):
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor or str(id(item))
name = item.objectName() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
@@ -458,7 +458,7 @@ class Crosshair(QObject):
)
self.coordinatesClicked1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor or str(id(item))
name = item.objectName() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
@@ -12,15 +14,20 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.clickable_label import ClickableLabel
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class ExpandableGroupFrame(QFrame):
expansion_state_changed = Signal()
EXPANDED_ICON_NAME: str = "collapse_all"
COLLAPSED_ICON_NAME: str = "expand_all"
def __init__(self, title: str, parent: QWidget | None = None, expanded: bool = True) -> None:
def __init__(
self, parent: QWidget | None = None, title: str = "", expanded: bool = True, icon: str = ""
) -> None:
super().__init__(parent=parent)
self._expanded = expanded
@@ -29,19 +36,28 @@ class ExpandableGroupFrame(QFrame):
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
self._expansion_button = QToolButton()
self._update_icon()
self._title = QLabel(f"<b>{title}</b>")
self._title_layout.addWidget(self._expansion_button)
self._title = ClickableLabel(f"<b>{title}</b>")
self._title_icon = ClickableLabel()
self._title_layout.addWidget(self._title_icon)
self._title_layout.addWidget(self._title)
self.icon_name = icon
self._title_layout.addStretch(1)
self._expansion_button = QToolButton()
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)
@@ -50,7 +66,8 @@ class ExpandableGroupFrame(QFrame):
@SafeSlot()
def switch_expanded_state(self):
self.expanded = not self.expanded # type: ignore
self._update_icon()
self._update_expansion_icon()
self.expansion_state_changed.emit()
@SafeProperty(bool)
def expanded(self): # type: ignore
@@ -61,8 +78,9 @@ class ExpandableGroupFrame(QFrame):
self._expanded = expanded
self._contents.setVisible(expanded)
self.updateGeometry()
self.adjustSize()
def _update_icon(self):
def _update_expansion_icon(self):
self._expansion_button.setIcon(
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
if self.expanded
@@ -70,3 +88,36 @@ class ExpandableGroupFrame(QFrame):
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
)
)
@SafeProperty(str)
def icon_name(self): # type: ignore
return self._title_icon_name
@icon_name.setter
def icon_name(self, icon_name: str):
self._title_icon_name = icon_name
self._set_title_icon(self._title_icon_name)
def _set_title_icon(self, icon_name: str):
if icon_name:
self._title_icon.setVisible(True)
self._title_icon.setPixmap(
material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=True)
)
else:
self._title_icon.setVisible(False)
# Application example
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
frame = ExpandableGroupFrame()
layout = QVBoxLayout()
frame.set_layout(layout)
layout.addWidget(QLabel("test1"))
layout.addWidget(QLabel("test2"))
layout.addWidget(QLabel("test3"))
frame.show()
app.exec()

View File

@@ -2,70 +2,99 @@ from __future__ import annotations
from decimal import Decimal
from types import NoneType
from typing import NamedTuple
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, QVBoxLayout, QWidget
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.forms_from_types.items import FormItemSpec, widget_from_type
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.forms_from_types.items import (
DynamicFormItem,
DynamicFormItemType,
FormItemSpec,
widget_from_type,
)
logger = bec_logger.logger
class GridRow(NamedTuple):
i: int
label: QLabel
widget: DynamicFormItem
class TypedForm(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "list_alt"
value_changed = Signal()
RPC = False
RPC = True
USER_ACCESS = ["enabled", "enabled.setter"]
def __init__(
self,
parent=None,
items: list[tuple[str, type]] | None = None,
form_item_specs: list[FormItemSpec] | None = None,
enabled: bool = True,
pretty_display: bool = False,
client=None,
**kwargs,
):
"""Widget with a list of form items based on a list of types.
Args:
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
Should be a type supported by the logic in items.py
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be
supplied.
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
Should be a type supported by the logic in items.py
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be
supplied.
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.
"""
if (items is not None and form_item_specs is not None) or (
items is None and form_item_specs is None
):
raise ValueError("Must specify one and only one of items and form_item_specs")
if items is not None and form_item_specs is not None:
logger.error(
"Must specify one and only one of items and form_item_specs! Ignoring `items`."
)
items = None
if items is None and form_item_specs is None:
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)
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._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._enabled: bool = enabled
self._form_grid_container = QWidget(parent=self)
self._form_grid_container.setSizePolicy(
QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding
)
self._form_grid = QWidget(parent=self._form_grid_container)
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout())
self._form_grid.setLayout(self._new_grid_layout())
self.populate()
self.enabled = self._enabled # type: ignore # QProperty
def populate(self):
self._clear_grid()
@@ -80,17 +109,20 @@ class TypedForm(BECWidget, QWidget):
grid.addWidget(label, row, 0)
widget = widget_from_type(item.item_type)(parent=self, spec=item)
widget.valueChanged.connect(self.value_changed)
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
grid.addWidget(widget, row, 1)
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
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"""
grid: QGridLayout = self._form_grid.layout() # type: ignore
for i in range(grid.rowCount()):
yield GridRow(i, grid.itemAtPosition(i, 0).widget(), grid.itemAtPosition(i, 1).widget())
def _dict_from_grid(self) -> dict[str, DynamicFormItemType]:
return {
grid.itemAtPosition(i, 0)
.widget()
.property("_model_field_name"): grid.itemAtPosition(i, 1)
.widget()
.getValue() # type: ignore # we only add 'DynamicFormItem's here
for i in range(grid.rowCount())
row.label.property("_model_field_name"): row.widget.getValue()
for row in self.enumerate_form_widgets()
}
def _clear_grid(self):
@@ -103,10 +135,13 @@ 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.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid)
self.update_size()
def update_size(self):
self._form_grid.adjustSize()
self._form_grid_container.adjustSize()
self.adjustSize()
@@ -114,23 +149,52 @@ class TypedForm(BECWidget, QWidget):
def _new_grid_layout(self):
new_grid = QGridLayout()
new_grid.setContentsMargins(0, 0, 0, 0)
new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
return new_grid
@property
def widget_dict(self):
return {
row.label.property("_model_field_name"): row.widget
for row in self.enumerate_form_widgets()
}
@SafeProperty(bool)
def enabled(self):
return self._enabled
@enabled.setter
def enabled(self, value: bool):
self._enabled = value
self.setEnabled(value)
class PydanticModelForm(TypedForm):
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
def __init__(self, parent=None, metadata_model: type[BaseModel] = None, client=None, **kwargs):
def __init__(
self,
parent=None,
data_model: type[BaseModel] | None = None,
enabled: bool = True,
pretty_display: bool = False,
client=None,
**kwargs,
):
"""
A form generated from a pydantic model.
Args:
metadata_model (type[BaseModel]): the model class for which to generate a form.
data_model (type[BaseModel]): the model class for which to generate a form.
enabled (bool): 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._md_schema = metadata_model
super().__init__(parent=parent, form_item_specs=self._form_item_specs(), client=client)
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
)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
@@ -147,9 +211,24 @@ class PydanticModelForm(TypedForm):
self._md_schema = schema
self.populate()
def set_data(self, data: BaseModel):
"""Fill the data for the form.
Args:
data (BaseModel): the data to enter into the form. Must be the same type as the
currently set schema, raises TypeError otherwise."""
if not self._md_schema:
raise ValueError("Schema not set - can't set data")
if not isinstance(data, self._md_schema):
raise TypeError(f"Supplied data {data} not of type {self._md_schema}")
for form_item in self.enumerate_form_widgets():
form_item.widget.setValue(getattr(data, form_item.label.property("_model_field_name")))
def _form_item_specs(self):
return [
FormItemSpec(name=name, info=info, item_type=info.annotation)
FormItemSpec(
name=name, info=info, item_type=info.annotation, pretty_display=self._pretty_display
)
for name, info in self._md_schema.model_fields.items()
]

View File

@@ -2,12 +2,12 @@ from __future__ import annotations
from abc import abstractmethod
from decimal import Decimal
from types import UnionType
from typing import Callable, Protocol
from types import GenericAlias, UnionType
from typing import Literal
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic.fields import FieldInfo
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import (
@@ -21,11 +21,13 @@ from qtpy.QtWidgets import (
QLayout,
QLineEdit,
QRadioButton,
QSizePolicy,
QSpinBox,
QToolButton,
QWidget,
)
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
from bec_widgets.widgets.editors.scan_metadata._util import (
clearable_required,
field_default,
@@ -46,9 +48,36 @@ class FormItemSpec(BaseModel):
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType
item_type: type | UnionType | GenericAlias
name: str
info: FieldInfo = FieldInfo()
pretty_display: bool = Field(
default=False,
description="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.",
)
@field_validator("item_type", mode="before")
@classmethod
def _validate_type(cls, v):
allowed_primitives = [str, int, float, bool]
if isinstance(v, (type, UnionType)):
return v
if isinstance(v, GenericAlias):
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, 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__)
if len(arg_types) != 1:
raise ValueError("Mixtures of literal types are not supported!")
if (t := arg_types.pop()) in allowed_primitives:
return t
raise ValueError(f"Literals of type {t} are not supported")
class ClearableBoolEntry(QWidget):
@@ -94,10 +123,20 @@ class ClearableBoolEntry(QWidget):
self._false.setToolTip(tooltip)
DynamicFormItemType = str | int | float | Decimal | bool | dict
class DynamicFormItem(QWidget):
valueChanged = Signal()
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
"""
Initializes the form item widget.
Args:
parent (QWidget | None, optional): The parent widget. Defaults to None.
spec (FormItemSpec): The specification for the form item.
"""
super().__init__(parent)
self._spec = spec
self._layout = QHBoxLayout()
@@ -107,11 +146,16 @@ class DynamicFormItem(QWidget):
self._desc = self._spec.info.description
self.setLayout(self._layout)
self._add_main_widget()
if clearable_required(spec.info):
self._add_clear_button()
self._main_widget: QWidget
self._main_widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
if not spec.pretty_display:
if clearable_required(spec.info):
self._add_clear_button()
else:
self._set_pretty_display()
@abstractmethod
def getValue(self): ...
def getValue(self) -> DynamicFormItemType: ...
@abstractmethod
def setValue(self, value): ...
@@ -121,6 +165,9 @@ class DynamicFormItem(QWidget):
"""Add the main data entry widget to self._main_widget and appply any
constraints from the field info"""
def _set_pretty_display(self):
self.setEnabled(False)
def _describe(self, pad=" "):
return pad + (self._desc if self._desc else "")
@@ -164,7 +211,7 @@ class StrMetadataField(DynamicFormItem):
def setValue(self, value: str):
if value is None:
self._main_widget.setText("")
self._main_widget.setText(value)
self._main_widget.setText(str(value))
class IntMetadataField(DynamicFormItem):
@@ -202,12 +249,12 @@ class FloatDecimalMetadataField(DynamicFormItem):
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
precision = field_precision(self._spec.info)
self._main_widget = QDoubleSpinBox()
self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._spec.info, int)
min_, max_ = field_limits(self._spec.info, float, precision)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
precision = field_precision(self._spec.info)
if precision:
self._main_widget.setDecimals(precision)
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
@@ -224,10 +271,10 @@ class FloatDecimalMetadataField(DynamicFormItem):
return self._default
return self._main_widget.value()
def setValue(self, value: float):
def setValue(self, value: float | Decimal):
if value is None:
self._main_widget.clear()
self._main_widget.setValue(value)
self._main_widget.setValue(float(value))
class BoolMetadataField(DynamicFormItem):
@@ -251,6 +298,27 @@ class BoolMetadataField(DynamicFormItem):
self._main_widget.setChecked(value)
class DictMetadataField(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)
def _set_pretty_display(self):
self._main_widget.set_button_visibility(False)
super()._set_pretty_display()
def _add_main_widget(self) -> None:
self._main_widget = DictBackedTable(self, [])
self._layout.addWidget(self._main_widget)
self._main_widget.setToolTip(self._describe(""))
def getValue(self):
return self._main_widget.dump_dict()
def setValue(self, value):
self._main_widget.replace_data(value)
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
if annotation in [str, str | None]:
return StrMetadataField
@@ -260,6 +328,14 @@ def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormIte
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

View File

@@ -0,0 +1,21 @@
import bec_qthemes
def pretty_display_theme(theme: str = "dark"):
palette = bec_qthemes.load_palette(theme)
foreground = palette.text().color().name()
background = palette.base().color().name()
border = palette.shadow().color().name()
accent = palette.accent().color().name()
return f"""
QWidget {{color: {foreground}; background-color: {background}}}
QLabel {{ font-weight: bold; }}
QLineEdit,QLabel,QTreeView {{ border-style: solid; border-width: 2px; border-color: {border} }}
QRadioButton {{ color: {foreground}; }}
QRadioButton::indicator::checked {{ color: {accent}; }}
QCheckBox {{ color: {accent}; }}
"""
if __name__ == "__main__":
print(pretty_display_theme())

View File

@@ -8,6 +8,9 @@ from qtpy.QtCore import QObject
from bec_widgets.utils.name_utils import pascal_to_snake
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
_PARENT_ARG_REGEX = r".__init__\(\s*(?:parent\)|parent=parent,?|parent,?)"
_SELF_PARENT_ARG_REGEX = r".__init__\(\s*self,\s*(?:parent\)|parent=parent,?|parent,?)"
SUPER_INIT_REGEX = re.compile(r"super\(\)" + _PARENT_ARG_REGEX, re.MULTILINE)
class PluginFilenames(NamedTuple):
@@ -90,34 +93,20 @@ class DesignerPluginGenerator:
# Check if the widget class calls the super constructor with parent argument
init_source = inspect.getsource(self.widget.__init__)
cls_init_found = (
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
)
super_init_found = (
bool(
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
class_re = re.compile(base_cls[0].__name__ + _SELF_PARENT_ARG_REGEX, re.MULTILINE)
cls_init_found = class_re.search(init_source) is not None
super_self_re = re.compile(
rf"super\({base_cls[0].__name__}, self\)" + _PARENT_ARG_REGEX, re.MULTILINE
)
super_init_found = super_self_re.search(init_source) is not None
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
super_init_found = (
bool(init_source.find("super().__init__(parent=parent") > 0)
or bool(init_source.find("super().__init__(parent,") > 0)
or bool(init_source.find("super().__init__(parent)") > 0)
)
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
# for the new style classes, we only have one super call. We can therefore check if the
# number of __init__ calls is 2 (the class itself and the super class)
num_inits = re.findall(r"__init__", init_source)
if len(num_inits) == 2 and not super_init_found:
super_init_found = bool(
init_source.find("super().__init__(parent=parent") > 0
or init_source.find("super().__init__(parent,") > 0
or init_source.find("super().__init__(parent)") > 0
)
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
if not cls_init_found and not super_init_found:
raise ValueError(

View File

@@ -1,6 +1,6 @@
import os
from qtpy.QtCore import QSize
from qtpy.QtCore import QEvent, QSize
from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
@@ -168,6 +168,11 @@ class BECMainWindow(BECWidget, QMainWindow):
def change_theme(self, theme: str):
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()

View File

@@ -149,6 +149,25 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self._is_valid_input = False
self.update()
def validate_device(self, device: str) -> bool: # type: ignore[override]
"""
Extend validation so that previewsignal pseudodevices (labels like
``"eiger_preview"``) are accepted as valid choices.
The validation run only on device not on the previewsignal.
Args:
device: The text currently entered/selected.
Returns:
True if the device is a genuine BEC device *or* one of the
whitelisted previewsignal entries.
"""
idx = self.findText(device)
if idx >= 0 and isinstance(self.itemData(idx), tuple):
device = self.itemData(idx)[0] # type: ignore[assignment]
return super().validate_device(device)
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel

View File

@@ -89,6 +89,7 @@ class ScanControl(BECWidget, QWidget):
self.config.allowed_scans = allowed_scans
self._scan_metadata: dict | None = None
self._metadata_form = ScanMetadata(parent=self)
# Create and set main layout
self._init_UI()
@@ -165,7 +166,6 @@ class ScanControl(BECWidget, QWidget):
self.layout.addStretch()
def _add_metadata_form(self):
self._metadata_form = ScanMetadata(parent=self)
self.layout.addWidget(self._metadata_form)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import Any
from qtpy import QtWidgets
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
@@ -45,7 +46,11 @@ class DictBackedTableModel(QAbstractTableModel):
def data(self, index, role=Qt.ItemDataRole):
if index.isValid():
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
if role in [
Qt.ItemDataRole.DisplayRole,
Qt.ItemDataRole.EditRole,
Qt.ItemDataRole.ToolTipRole,
]:
return str(self._data[index.row()][index.column()])
def setData(self, index, value, role):
@@ -57,6 +62,11 @@ class DictBackedTableModel(QAbstractTableModel):
return True
return False
def replaceData(self, data: dict):
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))
def update_disallowed_keys(self, keys: list[str]):
"""Set the list of keys which may not be used.
@@ -110,16 +120,16 @@ class DictBackedTableModel(QAbstractTableModel):
class DictBackedTable(QWidget):
delete_rows = Signal(list)
data_updated = Signal()
data_changed = Signal(dict)
def __init__(self, initial_data: list[list[str]]):
def __init__(self, parent: QWidget | None = None, initial_data: list[list[str]] = []):
"""Widget which uses a DictBackedTableModel to display an editable table
which can be extracted as a dict.
Args:
initial_data (list[list[str]]): list of key-value pairs to initialise with
"""
super().__init__()
super().__init__(parent)
self._layout = QHBoxLayout()
self.setLayout(self._layout)
@@ -127,13 +137,17 @@ class DictBackedTable(QWidget):
self._table_view = QTreeView()
self._table_view.setModel(self._table_model)
self._table_view.setSizePolicy(
QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
)
self._table_view.setAlternatingRowColors(True)
self._table_view.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
self._table_view.header().setSectionResizeMode(5, QtWidgets.QHeaderView.Stretch)
self._layout.addWidget(self._table_view)
self._button_holder = QWidget()
self._buttons = QVBoxLayout()
self._layout.addLayout(self._buttons)
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("-")
@@ -143,11 +157,17 @@ 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(self._emit_data_updated)
self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict()))
def _emit_data_updated(self, *args, **kwargs):
"""Just to swallow the args"""
self.data_updated.emit()
def set_button_visibility(self, value: bool):
self._button_holder.setVisible(value)
@SafeSlot()
def clear(self):
self._table_model.replaceData({})
def replace_data(self, data: dict):
self._table_model.replaceData(data)
def delete_selected_rows(self):
"""Delete rows which are part of the selection model"""
@@ -174,6 +194,6 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication([])
set_theme("dark")
window = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window.show()
app.exec()

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import sys
from decimal import Decimal
from math import inf, nextafter
from math import copysign, inf, nextafter
from typing import TYPE_CHECKING, TypeVar, get_args
from annotated_types import Ge, Gt, Le, Lt
@@ -23,16 +23,19 @@ _MAXFLOAT = sys.float_info.max
T = TypeVar("T", int, float, Decimal)
def field_limits(info: FieldInfo, type_: type[T]) -> tuple[T, T]:
def field_limits(info: FieldInfo, type_: type[T], prec: int | None = None) -> tuple[T, T]:
def _nextafter(x, y):
return nextafter(x, y) if prec is None else x + (10 ** (-prec)) * (copysign(1, y))
_min = _MININT if type_ is int else _MINFLOAT
_max = _MAXINT if type_ is int else _MAXFLOAT
for md in info.metadata:
if isinstance(md, Ge):
_min = type_(md.ge) # type: ignore
if isinstance(md, Gt):
_min = type_(md.gt) + 1 if type_ is int else nextafter(type_(md.gt), inf) # type: ignore
_min = type_(md.gt) + 1 if type_ is int else _nextafter(type_(md.gt), inf) # type: ignore
if isinstance(md, Lt):
_max = type_(md.lt) - 1 if type_ is int else nextafter(type_(md.lt), -inf) # type: ignore
_max = type_(md.lt) - 1 if type_ is int else _nextafter(type_(md.lt), -inf) # type: ignore
if isinstance(md, Le):
_max = type_(md.le) # type: ignore
return _min, _max # type: ignore

View File

@@ -16,6 +16,9 @@ logger = bec_logger.logger
class ScanMetadata(PydanticModelForm):
RPC = False
def __init__(
self,
parent=None,
@@ -36,16 +39,18 @@ class ScanMetadata(PydanticModelForm):
# self.populate() gets called in super().__init__
# so make sure self._additional_metadata exists
self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False)
self._additional_md_box = ExpandableGroupFrame(
parent, "Additional metadata", expanded=False
)
self._additional_md_box_layout = QHBoxLayout()
self._additional_md_box.set_layout(self._additional_md_box_layout)
self._additional_metadata = DictBackedTable(initial_extras or [])
self._additional_metadata = DictBackedTable(parent, initial_extras or [])
self._scan_name = scan_name or ""
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
self._additional_metadata.data_updated.connect(self.validate_form)
self._additional_metadata.data_changed.connect(self.validate_form)
super().__init__(parent=parent, metadata_model=self._md_schema, client=client, **kwargs)
super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs)
self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout.addWidget(self._additional_metadata)
@@ -127,6 +132,7 @@ if __name__ == "__main__": # pragma: no cover
w.setLayout(layout)
scan_metadata = ScanMetadata(
parent=w,
scan_name="grid_scan",
initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]],
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -21,9 +21,6 @@ logger = bec_logger.logger
# noinspection PyDataclass
class ImageItemConfig(ConnectionConfig): # TODO review config
parent_id: str | None = Field(None, description="The parent plot of the image.")
monitor: str | None = Field(None, description="The name of the monitor.")
monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.")
source: str | None = Field(None, description="The source of the curve.")
color_map: str | None = Field("plasma", description="The color map of the image.")
downsample: bool | None = Field(True, description="Whether to downsample the image.")
opacity: float | None = Field(1.0, description="The opacity of the image.")
@@ -43,6 +40,7 @@ class ImageItemConfig(ConnectionConfig): # TODO review config
class ImageItem(BECConnector, pg.ImageItem):
RPC = True
USER_ACCESS = [
"color_map",
@@ -69,12 +67,13 @@ class ImageItem(BECConnector, pg.ImageItem):
]
vRangeChangedManually = Signal(tuple)
removed = Signal(str)
def __init__(
self,
config: Optional[ImageItemConfig] = None,
gui_id: Optional[str] = None,
parent_image=None,
parent_image=None, # FIXME: rename to parent
**kwargs,
):
if config is None:
@@ -274,6 +273,8 @@ class ImageItem(BECConnector, pg.ImageItem):
self.buffer = []
self.max_len = 0
def remove(self):
self.parent().disconnect_monitor(self.config.monitor)
def remove(self, emit: bool = True):
self.clear()
super().remove()
if emit:
self.removed.emit(self.objectName())

View File

@@ -8,6 +8,7 @@ from qtpy.QtCore import QEvent, Qt
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QColorDialog,
QHBoxLayout,
QHeaderView,
QSpinBox,
QToolButton,
@@ -35,6 +36,28 @@ if TYPE_CHECKING:
from bec_widgets.widgets.plots.image.image import Image
class ROILockButton(QToolButton):
"""Keeps its icon and checked state in sync with a single ROI."""
def __init__(self, roi: BaseROI, parent=None):
super().__init__(parent)
self.setCheckable(True)
self._roi = roi
self.clicked.connect(self._toggle)
roi.movableChanged.connect(lambda _: self._sync())
self._sync()
def _toggle(self):
# checked -> locked -> movable = False
self._roi.movable = not self.isChecked()
def _sync(self):
movable = self._roi.movable
self.setChecked(not movable)
icon = "lock_open_right" if movable else "lock"
self.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False))
class ROIPropertyTree(BECWidget, QWidget):
"""
Two-column tree: [ROI] [Properties]
@@ -124,6 +147,24 @@ class ROIPropertyTree(BECWidget, QWidget):
self.expand_toggle.action.toggled.connect(_exp_toggled)
self.expand_toggle.action.setChecked(False)
# Lock/Unlock all ROIs
self.lock_all_action = MaterialIconAction(
"lock_open_right", "Lock/Unlock all ROIs", checkable=True, parent=self
)
tb.add_action("Lock/Unlock all ROIs", self.lock_all_action, self)
def _lock_all(checked: bool):
# checked -> everything locked (movable = False)
for r in self.controller.rois:
r.movable = not checked
new_icon = material_icon(
"lock" if checked else "lock_open_right", size=(20, 20), convert_to_pixmap=False
)
self.lock_all_action.action.setIcon(new_icon)
self.lock_all_action.action.toggled.connect(_lock_all)
# colormap widget
self.cmap = BECColorMapWidget(cmap=self.controller.colormap)
tb.addWidget(QWidget()) # spacer
@@ -235,18 +276,30 @@ class ROIPropertyTree(BECWidget, QWidget):
self._temp_roi = None
self._set_roi_draw_mode(None)
# register via controller
final_roi.add_scale_handle()
self.controller.add_roi(final_roi)
return True
return super().eventFilter(obj, event)
# --------------------------------------------------------- controller slots
def _on_roi_added(self, roi: BaseROI):
# check the global setting from the toolbar
if self.lock_all_action.action.isChecked():
roi.movable = False
# parent row with blank action column, name in ROI column
parent = QTreeWidgetItem(self.tree, ["", "", ""])
parent.setText(self.COL_ROI, roi.label)
parent.setFlags(parent.flags() | Qt.ItemIsEditable)
# --- delete button in actions column ---
# --- actions widget (lock/unlock + delete) ---
actions_widget = QWidget()
actions_layout = QHBoxLayout(actions_widget)
actions_layout.setContentsMargins(0, 0, 0, 0)
actions_layout.setSpacing(3)
# lock / unlock toggle
lock_btn = ROILockButton(roi, parent=self)
actions_layout.addWidget(lock_btn)
# delete button
del_btn = QToolButton()
delete_icon = material_icon(
"delete",
@@ -256,8 +309,11 @@ class ROIPropertyTree(BECWidget, QWidget):
color=self.DELETE_BUTTON_COLOR,
)
del_btn.setIcon(delete_icon)
self.tree.setItemWidget(parent, self.COL_ACTION, del_btn)
del_btn.clicked.connect(lambda _=None, r=roi: self._delete_roi(r))
actions_layout.addWidget(del_btn)
# install composite widget into the tree
self.tree.setItemWidget(parent, self.COL_ACTION, actions_widget)
# color button
color_btn = ColorButtonNative(parent=self, color=roi.line_color)
self.tree.setItemWidget(parent, self.COL_PROPS, color_btn)
@@ -309,6 +365,12 @@ class ROIPropertyTree(BECWidget, QWidget):
for c in range(3):
self.tree.resizeColumnToContents(c)
def _toggle_movable(self, roi: BaseROI):
"""
Toggle the `movable` property of the given ROI.
"""
roi.movable = not roi.movable
def _on_roi_removed(self, roi: BaseROI):
item = self.roi_items.pop(roi, None)
if item:
@@ -345,7 +407,7 @@ if __name__ == "__main__": # pragma: no cover
import sys
import numpy as np
from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.plots.image.image import Image

View File

@@ -1,5 +1,5 @@
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate
from bec_widgets.utils.error_popups import SafeSlot
@@ -50,11 +50,58 @@ class MonitorSelectionToolbarBundle(ToolbarBundle):
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False))
# Connect slots, a device will be connected upon change of any combobox
self.device_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
self.dim_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
self.device_combo_box.currentTextChanged.connect(self.connect_monitor)
self.dim_combo_box.currentTextChanged.connect(self.connect_monitor)
QTimer.singleShot(0, self._adjust_and_connect)
def _adjust_and_connect(self):
"""
Adjust the size of the device combo box and populate it with preview signals.
Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing.
"""
self._populate_preview_signals()
self._reverse_device_items()
self.device_combo_box.setCurrentText("") # set again default to empty string
def _populate_preview_signals(self) -> None:
"""
Populate the device combo box with previewsignal devices in the
format '<device>_<signal>' and store the tuple(device, signal) in
the item's userData for later use.
"""
preview_signals = self.target_widget.client.device_manager.get_bec_signals("PreviewSignal")
for device, signal, signal_config in preview_signals:
label = signal_config.get("obj_name", f"{device}_{signal}")
self.device_combo_box.addItem(label, (device, signal, signal_config))
def _reverse_device_items(self) -> None:
"""
Reverse the current order of items in the device combo box while
keeping their userData and restoring the previous selection.
"""
current_text = self.device_combo_box.currentText()
items = [
(self.device_combo_box.itemText(i), self.device_combo_box.itemData(i))
for i in range(self.device_combo_box.count())
]
self.device_combo_box.clear()
for text, data in reversed(items):
self.device_combo_box.addItem(text, data)
if current_text:
self.device_combo_box.setCurrentText(current_text)
@SafeSlot()
def connect_monitor(self):
def connect_monitor(self, *args, **kwargs):
"""
Connect the target widget to the selected monitor based on the current device and dimension.
If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor.
"""
dim = self.dim_combo_box.currentText()
self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)
data = self.device_combo_box.currentData()
if isinstance(data, tuple):
self.target_widget.image(monitor=data, monitor_type="auto")
else:
self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)

View File

@@ -104,9 +104,12 @@ class BaseROI(BECConnector):
nameChanged = Signal(str)
penChanged = Signal()
movableChanged = Signal(bool)
USER_ACCESS = [
"label",
"label.setter",
"movable",
"movable.setter",
"line_color",
"line_color.setter",
"line_width",
@@ -127,6 +130,7 @@ class BaseROI(BECConnector):
label: str | None = None,
line_color: str | None = None,
line_width: int = 5,
movable: bool = True,
# all remaining pg.*ROI kwargs (pos, size, pen, …)
**pg_kwargs,
):
@@ -155,6 +159,7 @@ class BaseROI(BECConnector):
gui_id=gui_id,
removable=True,
invertible=True,
movable=movable,
**pg_kwargs,
)
@@ -162,8 +167,14 @@ class BaseROI(BECConnector):
self._line_color = line_color or "#ffffff"
self._line_width = line_width
self._description = True
self._movable = movable
self.setPen(mkPen(self._line_color, width=self._line_width))
# Reset Handles to avoid inherited handles from pyqtgraph
self.remove_scale_handles() # remove any existing handles from pyqtgraph.RectROI
if movable:
self.add_scale_handle() # add custom scale handles
def set_parent(self, parent: Image):
"""
Sets the parent image for this ROI.
@@ -182,6 +193,40 @@ class BaseROI(BECConnector):
"""
return self.parent_image
@property
def movable(self) -> bool:
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
return self._movable
@movable.setter
def movable(self, value: bool):
"""
Sets whether this ROI is movable.
If the new value is different from the current value, this method updates
the internal state and emits the penChanged signal.
Args:
value (bool): True to make the ROI movable, False to make it fixed.
"""
if value != self._movable:
self._movable = value
# All relevant properties from pyqtgraph to block movement
self.translatable = value
self.rotatable = value
self.resizable = value
self.removable = value
if value:
self.add_scale_handle() # add custom scale handles
else:
self.remove_scale_handles() # remove custom scale handles
self.movableChanged.emit(value)
@property
def label(self) -> str:
"""
@@ -337,8 +382,18 @@ class BaseROI(BECConnector):
)
def add_scale_handle(self):
"""Add scale handles to the ROI."""
return
def remove_scale_handles(self):
"""Remove all scale handles from the ROI."""
handles = self.handles
for i in range(len(handles)):
try:
self.removeHandle(0)
except IndexError:
continue
def set_position(self, x: float, y: float):
"""
Sets the position of the ROI.
@@ -355,12 +410,7 @@ class BaseROI(BECConnector):
if controller and self in controller.rois:
controller.remove_roi(self)
return # controller will call back into this method once deregistered
handles = self.handles
for i in range(len(handles)):
try:
self.removeHandle(0)
except IndexError:
continue
self.remove_scale_handles()
self.rpc_register.remove_rpc(self)
self.parent_image.plot_item.removeItem(self)
viewBox = self.parent_plot_item.vb
@@ -399,6 +449,7 @@ class RectangularROI(BaseROI, pg.RectROI):
label: str | None = None,
line_color: str | None = None,
line_width: int = 5,
movable: bool = True,
resize_handles: bool = True,
**extra_pg,
):
@@ -429,6 +480,7 @@ class RectangularROI(BaseROI, pg.RectROI):
pos=pos,
size=size,
pen=pen,
movable=movable,
**extra_pg,
)
@@ -437,6 +489,23 @@ class RectangularROI(BaseROI, pg.RectROI):
self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
self.handleHoverPen = fn.mkPen("lime", width=4)
def _normalized_edges(self) -> tuple[float, float, float, float]:
"""
Return rectangle edges as (left, bottom, right, top) with consistent
ordering even when the ROI has been inverted by its scale handles.
Returns:
tuple: A tuple containing the left, bottom, right, and top edges
of the ROI rectangle in normalized coordinates.
"""
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
x_left = min(x0, x0 + w)
x_right = max(x0, x0 + w)
y_bottom = min(y0, y0 + h)
y_top = max(y0, y0 + h)
return x_left, y_bottom, x_right, y_top
def add_scale_handle(self):
"""
Add scale handles at every corner and edge of the ROI.
@@ -458,24 +527,17 @@ class RectangularROI(BaseROI, pg.RectROI):
self.addScaleHandle([0, 0.5], [1, 0.5]) # left edge
self.addScaleHandle([1, 0.5], [0, 0.5]) # right edge
self.handlePen = fn.mkPen("#ffff00", width=5) # bright yellow outline
self.handleHoverPen = fn.mkPen("#00ffff", width=4) # cyan, thicker when hovered
self.handleBrush = (200, 200, 0, 120) # semi-transparent fill
self.handleHoverBrush = (0, 255, 255, 160)
def _on_region_changed(self):
"""
Handles ROI region change events.
Handles changes to the ROI's region.
This method is called whenever the ROI's position or size changes.
It calculates the new corner coordinates and emits the edgesChanged signal
with the updated coordinates.
"""
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
self.edgesChanged.emit(x0, y0, x0 + w, y0 + h)
viewBox = self.parent_plot_item.vb
viewBox.update()
x_left, y_bottom, x_right, y_top = self._normalized_edges()
self.edgesChanged.emit(x_left, y_bottom, x_right, y_top)
self.parent_plot_item.vb.update()
def mouseDragEvent(self, ev):
"""
@@ -489,9 +551,8 @@ class RectangularROI(BaseROI, pg.RectROI):
"""
super().mouseDragEvent(ev)
if ev.isFinish():
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
self.edgesReleased.emit(x0, y0, x0 + w, y0 + h)
x_left, y_bottom, x_right, y_top = self._normalized_edges()
self.edgesReleased.emit(x_left, y_bottom, x_right, y_top)
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
"""
@@ -510,17 +571,16 @@ class RectangularROI(BaseROI, pg.RectROI):
if typed is None:
typed = self.description
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
x1, y1 = x0 + w, y0 + h
x_left, y_bottom, x_right, y_top = self._normalized_edges()
if typed:
return {
"bottom_left": (x0, y0),
"bottom_right": (x1, y0),
"top_left": (x0, y1),
"top_right": (x1, y1),
"bottom_left": (x_left, y_bottom),
"bottom_right": (x_right, y_bottom),
"top_left": (x_left, y_top),
"top_right": (x_right, y_top),
}
return ((x0, y0), (x1, y0), (x0, y1), (x1, y1))
return (x_left, y_bottom), (x_right, y_bottom), (x_left, y_top), (x_right, y_top)
def _lookup_scene_image(self):
"""
@@ -568,6 +628,7 @@ class CircularROI(BaseROI, pg.CircleROI):
label: str | None = None,
line_color: str | None = None,
line_width: int = 5,
movable: bool = True,
**extra_pg,
):
"""
@@ -599,10 +660,19 @@ class CircularROI(BaseROI, pg.CircleROI):
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):
"""
Adds scale handles to the circular ROI.
"""
self._addHandles() # wrapper around pg.CircleROI._addHandles
def _on_region_changed(self):
"""
@@ -654,7 +724,7 @@ class CircularROI(BaseROI, pg.CircleROI):
if typed is None:
typed = self.description
d = self.state["size"][0]
d = abs(self.state["size"][0])
cx = self.pos().x() + d / 2
cy = self.pos().y() + d / 2

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import json
from typing import Literal
from typing import Any, Literal
import lmfit
import numpy as np
@@ -163,7 +163,7 @@ class Waveform(PlotBase):
self._async_curves = []
self._slice_index = None
self._dap_curves = []
self._mode: Literal["none", "sync", "async", "mixed"] = "none"
self._mode = None
# Scan data
self._scan_done = True # means scan is not running
@@ -1139,7 +1139,7 @@ class Waveform(PlotBase):
QTimer.singleShot(100, self.update_sync_curves)
QTimer.singleShot(300, self.update_sync_curves)
def _fetch_scan_data_and_access(self):
def _fetch_scan_data_and_access(self) -> tuple[dict, str] | tuple[None, None]:
"""
Decide whether the widget is in live or historical mode
and return the appropriate data dict and access key.
@@ -1153,7 +1153,7 @@ class Waveform(PlotBase):
self.update_with_scan_history(-1)
if self.scan_item is None:
logger.info("No scan executed so far; skipping device curves categorisation.")
return "none", "none"
return None, None
if hasattr(self.scan_item, "live_data"):
# Live scan
@@ -1169,7 +1169,7 @@ class Waveform(PlotBase):
"""
if self.scan_item is None:
logger.info("No scan executed so far; skipping device curves categorisation.")
return "none"
return
data, access_key = self._fetch_scan_data_and_access()
for curve in self._sync_curves:
device_name = curve.config.signal.name
@@ -1177,9 +1177,8 @@ class Waveform(PlotBase):
if access_key == "val":
device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
else:
device_data = (
data.get(device_name, {}).get(device_entry, {}).read().get("value", None)
)
entry_obj = data.get(device_name, {}).get(device_entry)
device_data = entry_obj.read()["value"] if entry_obj else None
x_data = self._get_x_data(device_name, device_entry)
if x_data is not None:
if len(x_data) == 1:
@@ -1217,7 +1216,8 @@ class Waveform(PlotBase):
if self._skip_large_dataset_check is False:
if not self._check_dataset_size_and_confirm(dataset_obj, device_entry):
continue # user declined to load; skip this curve
device_data = dataset_obj.get(device_entry, {}).read().get("value", None)
entry_obj = dataset_obj.get(device_entry, None)
device_data = entry_obj.read()["value"] if entry_obj else None
# if shape is 2D cast it into 1D and take the last waveform
if len(np.shape(device_data)) > 1:
@@ -1549,15 +1549,21 @@ class Waveform(PlotBase):
if access_key == "val": # live data
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, [0])
else: # history data
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", [0])
entry_obj = data.get(x_name, {}).get(x_entry)
x_data = entry_obj.read()["value"] if entry_obj else [0]
new_suffix = f" (custom: {x_name}-{x_entry})"
# 2 User wants timestamp
if self.x_axis_mode["name"] == "timestamp":
if access_key == "val": # live
timestamps = data[device_name][device_entry].timestamps
x_data = data.get(device_name, {}).get(device_entry, None)
if x_data is None:
return None
else:
timestamps = x_data.timestamps
else: # history data
timestamps = data[device_name][device_entry].read().get("timestamp", [0])
entry_obj = data.get(device_name, {}).get(device_entry)
timestamps = entry_obj.read()["timestamp"] if entry_obj else [0]
x_data = timestamps
new_suffix = " (timestamp)"
@@ -1584,7 +1590,8 @@ class Waveform(PlotBase):
if access_key == "val":
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
else:
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
entry_obj = data.get(x_name, {}).get(x_entry)
x_data = entry_obj.read()["value"] if entry_obj else None
new_suffix = f" (auto: {x_name}-{x_entry})"
self._update_x_label_suffix(new_suffix)
return x_data
@@ -1637,7 +1644,7 @@ class Waveform(PlotBase):
self.update_with_scan_history(-1)
if self.scan_item is None:
logger.info("No scan executed so far; skipping device curves categorisation.")
return "none"
return None
if hasattr(self.scan_item, "live_data"):
readout_priority = self.scan_item.status_message.info["readout_priority"] # live data

View File

@@ -1,15 +1,22 @@
import os
import re
from typing import Optional
from functools import partial
from bec_lib.callback_handler import EventType
from bec_lib.logger import bec_logger
from bec_lib.messages import ConfigAction
from pyqtgraph import SignalProxy
from qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QListWidgetItem, QVBoxLayout, QWidget
from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QVBoxLayout, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon
logger = bec_logger.logger
class DeviceBrowser(BECWidget, QWidget):
@@ -23,18 +30,18 @@ class DeviceBrowser(BECWidget, QWidget):
def __init__(
self,
parent: Optional[QWidget] = None,
parent: QWidget | None = None,
config=None,
client=None,
gui_id: Optional[str] = None,
gui_id: str | None = None,
**kwargs,
) -> None:
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
self.ui = None
self.ini_ui()
self.dev_list: QListWidget = self.ui.device_list
self.dev_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
self.proxy_device_update = SignalProxy(
self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list
)
@@ -43,6 +50,7 @@ class DeviceBrowser(BECWidget, QWidget):
)
self.device_update.connect(self.update_device_list)
self.init_device_list()
self.update_device_list()
def ini_ui(self) -> None:
@@ -50,14 +58,12 @@ class DeviceBrowser(BECWidget, QWidget):
Initialize the UI by loading the UI file and setting the layout.
"""
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
ui_file_path = os.path.join(os.path.dirname(__file__), "device_browser.ui")
self.ui = UILoader(self).loader(ui_file_path)
layout.addWidget(self.ui)
self.setLayout(layout)
def on_device_update(self, action: str, content: dict) -> None:
def on_device_update(self, action: ConfigAction, content: dict) -> None:
"""
Callback for device update events. Triggers the device_update signal.
@@ -68,8 +74,43 @@ class DeviceBrowser(BECWidget, QWidget):
if action in ["add", "remove", "reload"]:
self.device_update.emit()
@Slot()
def update_device_list(self) -> None:
def init_device_list(self):
self.dev_list.clear()
self._device_items: dict[str, QListWidgetItem] = {}
def _updatesize(item: QListWidgetItem, device_item: DeviceItem):
device_item.adjustSize()
item.setSizeHint(QSize(device_item.width(), device_item.height()))
logger.debug(f"Adjusting {item} size to {device_item.width(), device_item.height()}")
with RPCRegister.delayed_broadcast():
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)
)
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", "")
device_item.setToolTip(tooltip)
device_item.broadcast_size_hint.connect(item.setSizeHint)
item.setSizeHint(device_item.sizeHint())
self.dev_list.setItemWidget(item, device_item)
self.dev_list.addItem(item)
self._device_items[device] = item
@SafeSlot()
def reset_device_list(self) -> None:
self.init_device_list()
self.update_device_list()
@SafeSlot()
@SafeSlot(str)
def update_device_list(self, *_) -> None:
"""
Update the device list based on the filter input.
There are two ways to trigger this function:
@@ -80,23 +121,14 @@ class DeviceBrowser(BECWidget, QWidget):
"""
filter_text = self.ui.filter_input.text()
try:
regex = re.compile(filter_text, re.IGNORECASE)
self.regex = re.compile(filter_text, re.IGNORECASE)
except re.error:
regex = None # Invalid regex, disable filtering
dev_list = self.ui.device_list
dev_list.clear()
self.regex = None # Invalid regex, disable filtering
for device in self.dev:
self._device_items[device].setHidden(False)
return
for device in self.dev:
if regex is None or regex.search(device):
item = QListWidgetItem(dev_list)
device_item = DeviceItem(device)
# pylint: disable=protected-access
tooltip = self.dev[device]._config.get("description", "")
device_item.setToolTip(tooltip)
item.setSizeHint(device_item.sizeHint())
dev_list.setItemWidget(item, device_item)
dev_list.addItem(item)
self._device_items[device].setHidden(not self.regex.search(device))
if __name__ == "__main__": # pragma: no cover
@@ -104,10 +136,10 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.colors import set_theme
app = QApplication(sys.argv)
apply_theme("light")
set_theme("light")
widget = DeviceBrowser()
widget.show()
sys.exit(app.exec_())

View File

@@ -2,10 +2,18 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.logger import bec_logger
from qtpy.QtCore import QMimeData, Qt
from qtpy.QtCore import QMimeData, QSize, Qt, Signal
from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget
from qtpy.QtWidgets import QApplication, QHBoxLayout, 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
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtGui import QMouseEvent
@@ -13,26 +21,77 @@ if TYPE_CHECKING: # pragma: no cover
logger = bec_logger.logger
class DeviceItem(QWidget):
def __init__(self, device: str) -> None:
super().__init__()
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:
super().__init__(parent, title=device, expanded=False, icon=icon)
self._drag_pos = None
self._expanded_first_time = False
self._data = None
self.device = device
layout = QHBoxLayout()
layout.setContentsMargins(10, 2, 10, 2)
self.label = QLabel(device)
layout.addWidget(self.label)
self.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
self.set_layout(layout)
self.setStyleSheet(
"""
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
"""
)
self.adjustSize()
self._title.clicked.connect(self.switch_expanded_state)
self._title_icon.clicked.connect(self.switch_expanded_state)
@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._contents.layout().addWidget(self.form)
if self._data:
self.form.set_data(self._data)
self.broadcast_size_hint.emit(self.sizeHint())
super().switch_expanded_state()
if self._expanded_first_time:
self.form.adjustSize()
self.updateGeometry()
if self._expanded:
self.form.set_pretty_display_theme()
self.adjustSize()
self.broadcast_size_hint.emit(self.sizeHint())
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."""
self._data = DeviceConfigModel.model_validate(config_dict)
if self._expanded_first_time:
self.form.set_data(self._data)
def mousePressEvent(self, event: QMouseEvent) -> None:
super().mousePressEvent(event)
@@ -63,6 +122,25 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = DeviceItem("Device")
widget = QWidget()
layout = QHBoxLayout()
widget.setLayout(layout)
item = DeviceItem("Device")
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_())

View File

@@ -0,0 +1,11 @@
from bec_lib.device import Device
def map_device_type_to_icon(device_obj: Device) -> str:
"""Associate device types with material icon names"""
match device_obj._info.get("device_base_class", "").lower():
case "positioner":
return "precision_manufacturing"
case "signal":
return "vital_signs"
return "deployed_code"

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.10.3"
version = "2.13.2"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -13,15 +13,15 @@ classifiers = [
"Topic :: Scientific/Engineering",
]
dependencies = [
"bec_ipython_client>=2.21.4, <=4.0", # needed for jupyter console
"bec_lib>=3.29, <=4.0",
"bec_ipython_client>=3.38, <=4.0", # needed for jupyter console
"bec_lib>=3.38, <=4.0",
"bec_qthemes~=0.7, >=0.7",
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"pydantic~=2.0",
"pyqtgraph~=0.13",
"PySide6~=6.8.2",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
]

View File

@@ -145,7 +145,14 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
# Check that the number of top level widgets is still the same. As the cleanup is done by the
# qt event loop, we need to wait for the qtbot to finish the cleanup
qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count)
try:
qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count)
except Exception as exc:
raise RuntimeError(
f"Widget {object_name} was not removed properly. The number of top level widgets "
f"is {len(gui._server_registry)} instead of {top_level_widgets_count}. The following "
f"widgets are still registered: {list(gui._server_registry.keys())}."
) from exc
# Number of widgets with parent_id == None, should be 2
widgets = [
widget

View File

@@ -29,7 +29,6 @@ def image_widget_with_crosshair(qtbot):
image_item = pg.ImageItem()
image_item.setImage(np.random.rand(100, 100))
image_item.config = type("obj", (object,), {"monitor": "test"})
widget.addItem(image_item)
plot_item = widget.getPlotItem()
@@ -99,6 +98,7 @@ def test_mouse_moved_signals_outside(plot_widget_with_crosshair):
def test_mouse_moved_signals_2D(image_widget_with_crosshair):
crosshair, plot_item = image_widget_with_crosshair
image_item = plot_item.items[0]
emitted_values_2D = []
@@ -113,7 +113,7 @@ def test_mouse_moved_signals_2D(image_widget_with_crosshair):
crosshair.mouse_moved(event_mock)
assert emitted_values_2D == [("test", 21, 55)]
assert emitted_values_2D == [(str(id(image_item)), 21, 55)]
def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair):

View File

@@ -5,13 +5,20 @@ 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 .client_mocks import mocked_client
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QListWidgetItem
from bec_widgets.widgets.services.device_browser import DeviceItem
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
# pylint: disable=no-member
# pylint: disable=missing-function-docstring
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
@pytest.fixture
@@ -30,22 +37,24 @@ def test_device_browser_init_with_devices(device_browser):
assert device_list.count() == len(device_browser.dev)
def test_device_browser_filtering(qtbot, device_browser):
@pytest.mark.parametrize(
["search_term", "expected_num_visible"],
[("sam", 3), ("nonexistent", 0), ("", -1), (r"(\)", -1)],
)
def test_device_browser_filtering(
qtbot, device_browser, search_term: str, expected_num_visible: int
):
"""
Test that the device browser is able to filter the device list.
"""
device_list = device_browser.ui.device_list
device_browser.ui.filter_input.setText("sam")
qtbot.wait(1000)
assert device_list.count() == 3
expected = expected_num_visible if expected_num_visible >= 0 else len(device_browser.dev)
device_browser.ui.filter_input.setText("nonexistent")
qtbot.wait(1000)
assert device_list.count() == 0
def num_visible(item_dict):
return len(list(filter(lambda i: not i.isHidden(), item_dict.values())))
device_browser.ui.filter_input.setText("")
qtbot.wait(1000)
assert device_list.count() == len(device_browser.dev)
device_browser.ui.filter_input.setText(search_term)
qtbot.wait(100)
assert num_visible(device_browser._device_items) == expected
def test_device_item_mouse_press_event(device_browser, qtbot):
@@ -55,7 +64,38 @@ def test_device_item_mouse_press_event(device_browser, qtbot):
# Simulate a left mouse press event on the device item
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
qtbot.mouseClick(widget.label, Qt.MouseButton.LeftButton)
qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton)
def test_update_event_captured(device_browser, qtbot):
device_browser.update_device_list = mock.MagicMock()
device_browser.update_device_list.assert_not_called()
device_browser.on_device_update("remove", {})
device_browser.update_device_list.assert_called_once()
device_browser.on_device_update("", {})
def test_device_item_expansion(device_browser, qtbot):
"""
Test that the form is displayed when the item is expanded, and that the expansion is triggered
by clicking on the expansion button, the title, or the device icon
"""
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
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)
assert widget.expanded
assert (name_field := form.widget_dict.get("name")) is not None
assert name_field.getValue() == "samx"
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
assert not widget.expanded
qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton)
qtbot.waitUntil(lambda: widget.expanded, timeout=500)
qtbot.mouseClick(widget._title_icon, Qt.MouseButton.LeftButton)
qtbot.waitUntil(lambda: not widget.expanded, timeout=500)
def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qtbot):
@@ -67,7 +107,7 @@ def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qt
device_name = widget.device
with mock.patch("qtpy.QtGui.QDrag.exec_") as mock_exec:
with mock.patch("qtpy.QtGui.QDrag.setMimeData") as mock_set_mimedata:
qtbot.mousePress(widget.label, Qt.MouseButton.LeftButton, pos=QPoint(0, 0))
qtbot.mousePress(widget._title, Qt.MouseButton.LeftButton, pos=QPoint(0, 0))
qtbot.mouseMove(widget, pos=QPoint(10, 10))
qtbot.mouseRelease(widget, Qt.MouseButton.LeftButton)
mock_set_mimedata.assert_called_once()

View File

@@ -48,12 +48,22 @@ class MyWidget(QWidget):
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
super(QWidget, self).__init__(parent)"""
super(QWidget, self).__init__(parent)
""",
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
super(QWidget, self).__init__(parent=parent)
""",
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
super(QWidget, self).__init__(
parent=parent,
other=arguments,
)
""",
]
)

View File

@@ -0,0 +1,60 @@
import sys
from typing import Literal
import pytest
from pydantic import ValidationError
from pydantic.fields import FieldInfo
from bec_widgets.utils.forms_from_types.items import FormItemSpec
@pytest.mark.skipif(sys.version_info < (3, 11), reason="Generic types don't support this in 3.10")
@pytest.mark.parametrize(
["input", "validity"],
[
({}, False),
({"item_type": int, "name": "test", "info": FieldInfo(), "pretty_display": True}, True),
(
{
"item_type": dict[dict, dict],
"name": "test",
"info": FieldInfo(),
"pretty_display": True,
},
False,
),
(
{
"item_type": dict[str, str],
"name": "test",
"info": FieldInfo(),
"pretty_display": True,
},
True,
),
(
{
"item_type": Literal["a", "b"],
"name": "test",
"info": FieldInfo(),
"pretty_display": True,
},
True,
),
(
{
"item_type": Literal["a", 2],
"name": "test",
"info": FieldInfo(),
"pretty_display": True,
},
False,
),
],
)
def test_form_item_spec(input, validity):
if validity:
assert FormItemSpec.model_validate(input)
else:
with pytest.raises(ValidationError):
FormItemSpec.model_validate(input)

View File

@@ -0,0 +1,80 @@
from unittest import mock
import pyqtgraph as pg
import pytest
from bec_widgets.widgets.plots.image.image_base import ImageLayerManager
from bec_widgets.widgets.plots.image.image_item import ImageItem
@pytest.fixture()
def image_layer_manager():
"""Fixture to create an instance of ImageLayer."""
layer = ImageLayerManager(parent=None, plot_item=mock.MagicMock(spec=pg.PlotItem))
yield layer
layer.clear()
def test_image_layer_manager_initialization(image_layer_manager):
"""Test the initialization of the ImageLayer."""
assert isinstance(image_layer_manager, ImageLayerManager)
assert image_layer_manager.plot_item is not None
def test_add_image_layer(image_layer_manager):
"""Test adding an image layer to the ImageLayerManager."""
layer = image_layer_manager.add(name="Test Layer")
assert layer.image.zValue() == 0
layer2 = image_layer_manager.add(name="Test Layer 2")
assert layer2.image.zValue() == 1
layer3 = image_layer_manager.add(name="Test Layer 3", z_position="top")
assert layer3.image.zValue() == 2
layer4 = image_layer_manager.add(name="Test Layer 4", z_position="bottom")
assert layer4.image.zValue() == -1
def test_remove_image_layer(image_layer_manager):
"""Test removing an image layer from the ImageLayerManager."""
layer = image_layer_manager.add(name="Test Layer")
assert len(image_layer_manager) == 1
image_layer_manager.remove(layer)
assert len(image_layer_manager) == 0
def test_clear_image_layers(image_layer_manager):
"""Test clearing all image layers from the ImageLayerManager."""
layer = image_layer_manager.add(name="Test Layer")
assert len(image_layer_manager) == 1
image_layer_manager.clear()
assert len(image_layer_manager) == 0
def test_image_layer_manager_getitem(image_layer_manager):
"""Test getting an image layer by index."""
layer = image_layer_manager.add(name="Test Layer")
assert image_layer_manager["Test Layer"] == layer
with pytest.raises(TypeError):
_ = image_layer_manager[1]
image_layer_manager.remove("Test Layer")
assert len(image_layer_manager) == 0
def test_image_layer_iteration(image_layer_manager):
"""Test iterating over image layers."""
layer = image_layer_manager.add(name="Test Layer")
assert list(image_layer_manager) == [layer]
layer2 = image_layer_manager.add(name="Test Layer 2")
assert list(image_layer_manager) == [layer, layer2]
layer3 = image_layer_manager.add()
assert list(image_layer_manager) == [layer, layer2, layer3]
names = list(image_layer_manager.layers.keys())
assert names == ["Test Layer", "Test Layer 2", "image_layer_0"]

View File

@@ -148,10 +148,10 @@ def test_delete_roi_button(roi_tree, image_widget, qtbot):
roi = image_widget.add_roi(kind="rect", name="to_delete")
item = roi_tree.roi_items[roi]
# Get the delete button
del_btn = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
action_widget = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
layout = action_widget.layout()
# Click the delete button
del_btn = layout.itemAt(1).widget()
del_btn.click()
qtbot.wait(200)
@@ -331,3 +331,67 @@ def test_add_roi_from_toolbar(qtbot, mocked_client):
# Verify it's a circle ROI
assert isinstance(new_roi, CircularROI)
def test_roi_lock_button(roi_tree, image_widget, qtbot):
"""Verify the individual lock button toggles ROI.movable."""
roi = image_widget.add_roi(kind="rect", name="lock_test")
item = roi_tree.roi_items[roi]
# Lock button is the first widget in the Actions layout
action_widget = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
lock_btn = action_widget.layout().itemAt(0).widget()
# Initially unlocked
assert roi.movable
assert not lock_btn.isChecked()
# Lock it
lock_btn.click()
qtbot.wait(200)
assert not roi.movable
assert lock_btn.isChecked()
# Unlock again
lock_btn.click()
qtbot.wait(200)
assert roi.movable
assert not lock_btn.isChecked()
def test_global_lock_all_button(roi_tree, image_widget, qtbot):
"""Verify the toolbar lock-all action locks/unlocks every ROI."""
roi1 = image_widget.add_roi(kind="rect", name="g1")
roi2 = image_widget.add_roi(kind="circle", name="g2")
lock_all = roi_tree.lock_all_action.action
# Start unlocked
assert roi1.movable and roi2.movable
assert not lock_all.isChecked()
# Toggle → lock everything
lock_all.trigger()
qtbot.wait(200)
assert lock_all.isChecked()
assert not roi1.movable and not roi2.movable
# Toggle again → unlock everything
lock_all.trigger()
qtbot.wait(200)
assert not lock_all.isChecked()
assert roi1.movable and roi2.movable
def test_new_roi_respects_global_lock(roi_tree, image_widget, qtbot):
"""When the global lock-all toggle is active, newly added ROIs start locked."""
# Enable global lock
roi_tree.lock_all_action.action.setChecked(True)
qtbot.wait(100)
# Add ROI after lock enabled
roi = image_widget.add_roi(kind="rect", name="new_locked")
assert not roi.movable
# Disable global lock again
roi_tree.lock_all_action.action.setChecked(False)

View File

@@ -205,3 +205,29 @@ def test_roi_set_position(bec_image_widget_with_roi):
pos = roi.pos()
assert int(pos.x()) == 10
assert int(pos.y()) == 15
def test_roi_movable_property(bec_image_widget_with_roi, qtbot):
"""Verify BaseROI.movable toggles flags, handles, and emits a signal."""
_widget, roi, _ = bec_image_widget_with_roi
# defaults ROI is movable
assert roi.movable
assert roi.translatable and roi.rotatable and roi.resizable and roi.removable
assert len(roi.handles) > 0
# lock it
with qtbot.waitSignal(roi.movableChanged) as blocker:
roi.movable = False
assert blocker.args == [False]
assert not roi.movable
assert not (roi.translatable or roi.rotatable or roi.resizable or roi.removable)
assert len(roi.handles) == 0
# unlock again
with qtbot.waitSignal(roi.movableChanged) as blocker:
roi.movable = True
assert blocker.args == [True]
assert roi.movable
assert roi.translatable and roi.rotatable and roi.resizable and roi.removable
assert len(roi.handles) > 0

View File

@@ -1,6 +1,7 @@
import numpy as np
import pyqtgraph as pg
import pytest
from qtpy.QtCore import QPointF
from bec_widgets.widgets.plots.image.image import Image
from tests.unit_tests.client_mocks import mocked_client
@@ -61,8 +62,8 @@ def test_lock_aspect_ratio(qtbot, mocked_client):
def test_set_vrange(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.vrange = (10, 100)
assert bec_image_view.vrange == (10, 100)
bec_image_view.v_range = (10, 100)
assert bec_image_view.v_range == QPointF(10, 100)
assert bec_image_view.main_image.levels == (10, 100)
assert bec_image_view.main_image.config.v_range == (10, 100)
@@ -107,17 +108,86 @@ def test_enable_colorbar_with_vrange(qtbot, mocked_client, colorbar_type):
assert isinstance(bec_image_view._color_bar, pg.HistogramLUTItem)
assert bec_image_view.enable_full_colorbar is True
assert bec_image_view.config.color_bar == colorbar_type
assert bec_image_view.vrange == (0, 100)
assert bec_image_view.v_range == QPointF(0, 100)
assert bec_image_view.main_image.levels == (0, 100)
assert bec_image_view._color_bar is not None
##############################################
# Previewsignal update mechanism
def test_image_setup_preview_signal_1d(qtbot, mocked_client, monkeypatch):
"""
Ensure that calling .image() with a (device, signal, config) tuple representing
a 1D PreviewSignal connects using the 1D path and updates correctly.
"""
import numpy as np
view = create_widget(qtbot, Image, client=mocked_client)
signal_config = {
"obj_name": "waveform1d_img",
"signal_class": "PreviewSignal",
"describe": {"signal_info": {"ndim": 1}},
}
# Set the image monitor to the preview signal
view.image(monitor=("waveform1d", "img", signal_config))
# Subscriptions should indicate 1D preview connection
sub = view.subscriptions["main"]
assert sub.source == "device_monitor_1d"
assert sub.monitor_type == "1d"
assert sub.monitor == ("waveform1d", "img", signal_config)
# Simulate a waveform update from the dispatcher
waveform = np.arange(25, dtype=float)
view.on_image_update_1d({"data": waveform}, {"scan_id": "scan_test"})
assert view.main_image.raw_data.shape == (1, 25)
np.testing.assert_array_equal(view.main_image.raw_data[0], waveform)
def test_image_setup_preview_signal_2d(qtbot, mocked_client, monkeypatch):
"""
Ensure that calling .image() with a (device, signal, config) tuple representing
a 2D PreviewSignal connects using the 2D path and updates correctly.
"""
import numpy as np
view = create_widget(qtbot, Image, client=mocked_client)
signal_config = {
"obj_name": "eiger_img2d",
"signal_class": "PreviewSignal",
"describe": {"signal_info": {"ndim": 2}},
}
# Set the image monitor to the preview signal
view.image(monitor=("eiger", "img2d", signal_config))
# Subscriptions should indicate 2D preview connection
sub = view.subscriptions["main"]
assert sub.source == "device_monitor_2d"
assert sub.monitor_type == "2d"
assert sub.monitor == ("eiger", "img2d", signal_config)
# Simulate a 2D image update
test_data = np.arange(16, dtype=float).reshape(4, 4)
view.on_image_update_2d({"data": test_data}, {})
np.testing.assert_array_equal(view.main_image.image, test_data)
##############################################
# Device monitor endpoint update mechanism
def test_image_setup_image_2d(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.image(monitor="eiger", monitor_type="2d")
assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "device_monitor_2d"
assert bec_image_view.main_image.config.monitor_type == "2d"
assert bec_image_view.subscriptions["main"].source == "device_monitor_2d"
assert bec_image_view.subscriptions["main"].monitor_type == "2d"
assert bec_image_view.main_image.raw_data is None
assert bec_image_view.main_image.image is None
@@ -126,8 +196,8 @@ def test_image_setup_image_1d(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.image(monitor="eiger", monitor_type="1d")
assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "device_monitor_1d"
assert bec_image_view.main_image.config.monitor_type == "1d"
assert bec_image_view.subscriptions["main"].source == "device_monitor_1d"
assert bec_image_view.subscriptions["main"].monitor_type == "1d"
assert bec_image_view.main_image.raw_data is None
assert bec_image_view.main_image.image is None
@@ -136,8 +206,8 @@ def test_image_setup_image_auto(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.image(monitor="eiger", monitor_type="auto")
assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "auto"
assert bec_image_view.main_image.config.monitor_type == "auto"
assert bec_image_view.subscriptions["main"].source == "auto"
assert bec_image_view.subscriptions["main"].monitor_type == "auto"
assert bec_image_view.main_image.raw_data is None
assert bec_image_view.main_image.image is None
@@ -150,7 +220,7 @@ def test_image_data_update_2d(qtbot, mocked_client):
bec_image_view.on_image_update_2d(message, metadata)
np.testing.assert_array_equal(bec_image_view._main_image.image, test_data)
np.testing.assert_array_equal(bec_image_view.main_image.image, test_data)
def test_image_data_update_1d(qtbot, mocked_client):
@@ -160,10 +230,14 @@ def test_image_data_update_1d(qtbot, mocked_client):
metadata = {"scan_id": "scan_test"}
bec_image_view.on_image_update_1d({"data": waveform1}, metadata)
assert bec_image_view._main_image.raw_data.shape == (1, 50)
assert bec_image_view.main_image.raw_data.shape == (1, 50)
bec_image_view.on_image_update_1d({"data": waveform2}, metadata)
assert bec_image_view._main_image.raw_data.shape == (2, 60)
assert bec_image_view.main_image.raw_data.shape == (2, 60)
##############################################
# Toolbar and Actions Tests
def test_toolbar_actions_presence(qtbot, mocked_client):
@@ -207,8 +281,8 @@ def test_setting_vrange_with_colorbar(qtbot, mocked_client, colorbar_type):
elif colorbar_type == "full":
bec_image_view.enable_full_colorbar = True
bec_image_view.vrange = (0, 100)
assert bec_image_view.vrange == (0, 100)
bec_image_view.v_range = (0, 100)
assert bec_image_view.v_range == QPointF(0, 100)
assert bec_image_view.main_image.levels == (0, 100)
assert bec_image_view.main_image.config.v_range == (0, 100)
assert bec_image_view.v_min == 0
@@ -234,8 +308,8 @@ def test_setup_image_from_toolbar(qtbot, mocked_client):
bec_image_view.selection_bundle.dim_combo_box.setCurrentText("2d")
assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "device_monitor_2d"
assert bec_image_view.main_image.config.monitor_type == "2d"
assert bec_image_view.subscriptions["main"].source == "device_monitor_2d"
assert bec_image_view.subscriptions["main"].monitor_type == "2d"
assert bec_image_view.main_image.raw_data is None
assert bec_image_view.main_image.image is None
@@ -483,3 +557,96 @@ def test_roi_plot_data_from_image(qtbot, mocked_client):
# Horizontal slice (row)
h_slice, _ = y_items[0].getData()
np.testing.assert_array_equal(h_slice, test_data[2])
##############################################
# MonitorSelectionToolbarBundle specific tests
##############################################
def test_monitor_selection_reverse_device_items(qtbot, mocked_client):
"""
Verify that _reverse_device_items correctly reverses the order of items in the
device combobox while preserving the current selection.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
combo = bundle.device_combo_box
# Replace existing items with a deterministic list
combo.clear()
combo.addItem("samx", 1)
combo.addItem("samy", 2)
combo.addItem("samz", 3)
combo.setCurrentText("samy")
# Reverse the items
bundle._reverse_device_items()
# Order should be reversed and selection preserved
assert [combo.itemText(i) for i in range(combo.count())] == ["samz", "samy", "samx"]
assert combo.currentText() == "samy"
def test_monitor_selection_populate_preview_signals(qtbot, mocked_client, monkeypatch):
"""
Verify that _populate_preview_signals adds previewsignal devices to the combobox
with the correct userData.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
# Provide a deterministic fake device_manager with get_bec_signals
class _FakeDM:
def get_bec_signals(self, _filter):
return [
("eiger", "img", {"obj_name": "eiger_img"}),
("async_device", "img2", {"obj_name": "async_device_img2"}),
]
monkeypatch.setattr(view.client, "device_manager", _FakeDM())
initial_count = bundle.device_combo_box.count()
bundle._populate_preview_signals()
# Two new entries should have been added
assert bundle.device_combo_box.count() == initial_count + 2
# The first newly added item should carry tuple userData describing the device/signal
data = bundle.device_combo_box.itemData(initial_count)
assert isinstance(data, tuple) and data[0] == "eiger"
def test_monitor_selection_adjust_and_connect(qtbot, mocked_client, monkeypatch):
"""
Verify that _adjust_and_connect performs the full setup:
fills the combobox with preview signals,
reverses their order,
and resets the currentText to an empty string.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
# Deterministic fake device_manager
class _FakeDM:
def get_bec_signals(self, _filter):
return [("eiger", "img", {"obj_name": "eiger_img"})]
monkeypatch.setattr(view.client, "device_manager", _FakeDM())
combo = bundle.device_combo_box
# Start from a clean state
combo.clear()
combo.addItem("", None)
combo.setCurrentText("")
# Execute the method under test
bundle._adjust_and_connect()
# Expect exactly two items: preview label followed by the empty default
assert combo.count() == 2
# Because of the reversal, the preview label comes first
assert combo.itemText(0) == "eiger_img"
# Current selection remains empty
assert combo.currentText() == ""

View File

@@ -0,0 +1,80 @@
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,
)
# pylint: disable=no-member
# pylint: disable=missing-function-docstring
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
class ExampleSchema(BaseModel):
str_optional: str | None = Field(
None, title="Optional string", description="an optional string", max_length=23
)
str_required: str
bool_optional: bool | None = Field(None)
bool_required_default: bool = Field(True)
bool_required_nodefault: bool = Field()
int_default: int = Field(123)
int_nodefault_optional: int | None = Field(lt=-1, ge=-44)
float_nodefault: float
decimal_dp_limits_nodefault: Decimal = Field(decimal_places=2, gt=1, le=34.5)
TEST_DICT = {
"sample_name": "test name",
"str_optional": "None",
"str_required": "something",
"bool_optional": None,
"bool_required_default": True,
"bool_required_nodefault": False,
"int_default": 21,
"int_nodefault_optional": -10,
"float_nodefault": 123.456,
"decimal_dp_limits_nodefault": 34.5,
}
@pytest.fixture
def example_md():
return ExampleSchema.model_validate(TEST_DICT)
@pytest.fixture
def model_widget(qtbot):
widget = PydanticModelForm(data_model=ExampleSchema)
widget.populate()
qtbot.addWidget(widget)
yield widget
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)
def test_widget_set_data(model_widget: PydanticModelForm):
data = ExampleSchema.model_validate(TEST_DICT)
model_widget.set_data(data)
for key in [
"str_optional",
"str_required",
"bool_optional",
"bool_required_default",
"bool_required_nodefault",
"int_default",
"int_nodefault_optional",
"float_nodefault",
"decimal_dp_limits_nodefault",
]:
assert model_widget.widget_dict[key].getValue() == TEST_DICT[key]

View File

@@ -1,4 +1,5 @@
from decimal import Decimal
from typing import Set
import pytest
from bec_lib.metadata_schema import BasicScanMetadata
@@ -8,6 +9,7 @@ from qtpy.QtCore import QItemSelectionModel, QPoint, Qt
from bec_widgets.utils.forms_from_types.items import (
BoolMetadataField,
DictMetadataField,
DynamicFormItem,
FloatDecimalMetadataField,
IntMetadataField,
@@ -34,12 +36,13 @@ class ExampleSchema(BasicScanMetadata):
int_nodefault_optional: int | None = Field(lt=-1, ge=-44)
float_nodefault: float
decimal_dp_limits_nodefault: Decimal = Field(Decimal(1.23), decimal_places=2, gt=1, le=34.5)
unsupported_class: Json = Field(default_factory=dict)
dict_default: dict = Field(default_factory=dict)
unsupported_class: Json = Field(default=set())
TEST_DICT = {
"sample_name": "test name",
"str_optional": None,
"str_optional": "None",
"str_required": "something",
"bool_optional": None,
"bool_required_default": True,
@@ -47,8 +50,9 @@ TEST_DICT = {
"int_default": 21,
"int_nodefault_optional": -10,
"float_nodefault": pytest.approx(0.1),
"decimal_dp_limits_nodefault": pytest.approx(34),
"unsupported_class": '{"key": "value"}',
"decimal_dp_limits_nodefault": pytest.approx(34.5),
"dict_default": {"test_dict": "values"},
"unsupported_class": '["set", "item"]',
}
@@ -58,12 +62,11 @@ def example_md():
@pytest.fixture
def empty_metadata_widget():
def empty_metadata_widget(qtbot):
widget = ScanMetadata()
widget._additional_metadata._table_model._data = [["extra_field", "extra_data"]]
qtbot.addWidget(widget)
yield widget
widget._clear_grid()
widget.deleteLater()
@pytest.fixture
@@ -82,7 +85,8 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
int_nodefault_optional = widget._form_grid.layout().itemAtPosition(7, 1).widget()
float_nodefault = widget._form_grid.layout().itemAtPosition(8, 1).widget()
decimal_dp_limits_nodefault = widget._form_grid.layout().itemAtPosition(9, 1).widget()
unsupported_class = widget._form_grid.layout().itemAtPosition(10, 1).widget()
dict_default = widget._form_grid.layout().itemAtPosition(10, 1).widget()
unsupported_class = widget._form_grid.layout().itemAtPosition(11, 1).widget()
yield (
widget,
@@ -97,6 +101,7 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
"int_nodefault_optional": int_nodefault_optional,
"float_nodefault": float_nodefault,
"decimal_dp_limits_nodefault": decimal_dp_limits_nodefault,
"dict_default": dict_default,
"unsupported_class": unsupported_class,
},
)
@@ -112,7 +117,8 @@ def fill_commponents(components: dict[str, DynamicFormItem]):
components["int_nodefault_optional"].setValue(-10)
components["float_nodefault"].setValue(0.1)
components["decimal_dp_limits_nodefault"].setValue(456.789)
components["unsupported_class"].setValue(r'{"key": "value"}')
components["dict_default"].setValue({"test_dict": "values"})
components["unsupported_class"].setValue(r'["set", "item"]')
def test_griditems_are_correct_class(
@@ -129,6 +135,7 @@ def test_griditems_are_correct_class(
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)
@@ -168,14 +175,16 @@ def test_numbers_clipped_to_limits(
fill_commponents(components)
components["decimal_dp_limits_nodefault"].setValue(-56)
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(1.01)
widget.validate_form()
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(2)
assert widget._validity_message.text() == "No errors!"
@pytest.fixture
def table():
table = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
table = DictBackedTable(
initial_data=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]
)
yield table
table._table_model.deleteLater()
table._table_view.deleteLater()