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 | Date | |
|---|---|---|---|
|
|
0ae4b652a4 | ||
| 32fd959e67 | |||
|
|
73b1886bb8 | ||
| 9f853b0864 | |||
|
|
18636e723a | ||
| 594185dde9 | |||
| 46d7e3f517 | |||
| f9044996f6 | |||
|
|
03474cf7f7 | ||
| 9ef418bf55 | |||
| b3ce68070d | |||
|
|
784b54af6e | ||
| 3740ac8e32 | |||
| edfac87868 | |||
| 271116453d | |||
| 12f5233745 | |||
|
|
392ddf9d1a | ||
| 85705383e4 | |||
|
|
224863569f | ||
| 3e2544e52a | |||
|
|
4d5daf6557 | ||
| 718116afc3 | |||
| 2dda58f7d2 | |||
| 594912136e | |||
| 5188b38c86 | |||
| a10e6f7820 | |||
| e0e26c205b | |||
| 92d1d6435d | |||
| a25c1a8039 | |||
|
|
fed068f857 | ||
| 7eb2f54e0e | |||
| 92b89e7275 | |||
| a4f3117941 | |||
| 3e789ca35b | |||
| 92dade0950 | |||
| 4a343b2041 | |||
| c2b0c8c433 | |||
| 8a299a8268 | |||
| 99ecf6a18f | |||
| 4c0bd977fc | |||
| 7c47505c5a | |||
| e211e4d716 | |||
| 10f292def9 |
15
.github/workflows/stale-issues.yml
vendored
Normal file
15
.github/workflows/stale-issues.yml
vendored
Normal 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
|
||||
165
CHANGELOG.md
165
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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):
|
||||
|
||||
13
bec_widgets/utils/clickable_label.py
Normal file
13
bec_widgets/utils/clickable_label.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
21
bec_widgets/utils/forms_from_types/styles.py
Normal file
21
bec_widgets/utils/forms_from_types/styles.py
Normal 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())
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 preview‑signal pseudo‑devices (labels like
|
||||
``"eiger_preview"``) are accepted as valid choices.
|
||||
|
||||
The validation run only on device not on the preview‑signal.
|
||||
|
||||
Args:
|
||||
device: The text currently entered/selected.
|
||||
|
||||
Returns:
|
||||
True if the device is a genuine BEC device *or* one of the
|
||||
whitelisted preview‑signal 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
1065
bec_widgets/widgets/plots/image/image_base.py
Normal file
1065
bec_widgets/widgets/plots/image/image_base.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 preview‑signal 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_())
|
||||
|
||||
@@ -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_())
|
||||
|
||||
11
bec_widgets/widgets/services/device_browser/util.py
Normal file
11
bec_widgets/widgets/services/device_browser/util.py
Normal 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"
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
""",
|
||||
]
|
||||
)
|
||||
|
||||
60
tests/unit_tests/test_generated_form_items.py
Normal file
60
tests/unit_tests/test_generated_form_items.py
Normal 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)
|
||||
80
tests/unit_tests/test_image_layer.py
Normal file
80
tests/unit_tests/test_image_layer.py
Normal 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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
##############################################
|
||||
# Preview‑signal 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 1‑D PreviewSignal connects using the 1‑D 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 1‑D 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 2‑D PreviewSignal connects using the 2‑D 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 2‑D 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 2‑D 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 combo‑box 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 preview‑signal devices to the combo‑box
|
||||
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 set‑up:
|
||||
‑ fills the combo‑box 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() == ""
|
||||
|
||||
80
tests/unit_tests/test_pydantic_model_form.py
Normal file
80
tests/unit_tests/test_pydantic_model_form.py
Normal 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]
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user