bec-widgets 2.11.0__py3-none-any.whl → 2.12.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CHANGELOG.md +41 -0
- PKG-INFO +1 -1
- bec_widgets/tests/utils.py +2 -2
- bec_widgets/utils/clickable_label.py +13 -0
- bec_widgets/utils/colors.py +7 -4
- bec_widgets/utils/crosshair.py +2 -2
- bec_widgets/utils/expandable_frame.py +58 -7
- bec_widgets/utils/forms_from_types/forms.py +107 -28
- bec_widgets/utils/forms_from_types/items.py +88 -12
- bec_widgets/utils/forms_from_types/styles.py +21 -0
- bec_widgets/utils/generate_designer_plugin.py +10 -21
- bec_widgets/widgets/control/scan_control/scan_control.py +1 -1
- bec_widgets/widgets/editors/dict_backed_table.py +31 -11
- bec_widgets/widgets/editors/scan_metadata/_util.py +7 -4
- bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +10 -4
- bec_widgets/widgets/services/device_browser/device_browser.py +61 -29
- bec_widgets/widgets/services/device_browser/device_item/device_item.py +97 -19
- bec_widgets/widgets/services/device_browser/util.py +11 -0
- {bec_widgets-2.11.0.dist-info → bec_widgets-2.12.1.dist-info}/METADATA +1 -1
- {bec_widgets-2.11.0.dist-info → bec_widgets-2.12.1.dist-info}/RECORD +24 -21
- pyproject.toml +1 -1
- {bec_widgets-2.11.0.dist-info → bec_widgets-2.12.1.dist-info}/WHEEL +0 -0
- {bec_widgets-2.11.0.dist-info → bec_widgets-2.12.1.dist-info}/entry_points.txt +0 -0
- {bec_widgets-2.11.0.dist-info → bec_widgets-2.12.1.dist-info}/licenses/LICENSE +0 -0
CHANGELOG.md
CHANGED
@@ -1,6 +1,47 @@
|
|
1
1
|
# CHANGELOG
|
2
2
|
|
3
3
|
|
4
|
+
## v2.12.1 (2025-06-05)
|
5
|
+
|
6
|
+
### Bug Fixes
|
7
|
+
|
8
|
+
- **crosshair**: Emitted name from crosshair 2D is objectName of image or its id
|
9
|
+
([`3e2544e`](https://github.com/bec-project/bec_widgets/commit/3e2544e52a84b30a5acb4a7874025fa359a3c58d))
|
10
|
+
|
11
|
+
|
12
|
+
## v2.12.0 (2025-06-04)
|
13
|
+
|
14
|
+
### Bug Fixes
|
15
|
+
|
16
|
+
- Exclude metadata from RPC
|
17
|
+
([`718116a`](https://github.com/bec-project/bec_widgets/commit/718116afc3a724658c4cd57b76e93249a66a9ebd))
|
18
|
+
|
19
|
+
- Grid formatting in TypedForm
|
20
|
+
([`5949121`](https://github.com/bec-project/bec_widgets/commit/594912136e2118de1a4de5213c2f668952f28a84))
|
21
|
+
|
22
|
+
- Make generate plugin robust to multiline init
|
23
|
+
([`a10e6f7`](https://github.com/bec-project/bec_widgets/commit/a10e6f7820309d590e832f2bca44ca1db8ef72a1))
|
24
|
+
|
25
|
+
instead of str.find, use multiline regex with whitespace
|
26
|
+
|
27
|
+
- **device browser**: Mocks and utils for tests
|
28
|
+
([`e0e26c2`](https://github.com/bec-project/bec_widgets/commit/e0e26c205bf930d680e01910f87489decc7fbcdb))
|
29
|
+
|
30
|
+
### Features
|
31
|
+
|
32
|
+
- (#493) add dict to dynamic form types
|
33
|
+
([`92d1d64`](https://github.com/bec-project/bec_widgets/commit/92d1d6435d6e8c05851804eb76605a4abeec01bb))
|
34
|
+
|
35
|
+
- (#493) add helpers to dynamic form widgets
|
36
|
+
([`a25c1a8`](https://github.com/bec-project/bec_widgets/commit/a25c1a8039078c92789b717b3f8a553c75814c33))
|
37
|
+
|
38
|
+
- (#493) device browser to display config
|
39
|
+
([`5188b38`](https://github.com/bec-project/bec_widgets/commit/5188b38c86f543d2abc742411b64fa127c6c0c16))
|
40
|
+
|
41
|
+
- Add clickable label util
|
42
|
+
([`2dda58f`](https://github.com/bec-project/bec_widgets/commit/2dda58f7d2adf1f41c6ce4fad02d55bd9aa200fa))
|
43
|
+
|
44
|
+
|
4
45
|
## v2.11.0 (2025-06-04)
|
5
46
|
|
6
47
|
### Bug Fixes
|
PKG-INFO
CHANGED
bec_widgets/tests/utils.py
CHANGED
@@ -184,8 +184,8 @@ class FakePositioner(BECPositioner):
|
|
184
184
|
class Positioner(FakePositioner):
|
185
185
|
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
|
186
186
|
|
187
|
-
def __init__(self, name="test", limits=None, read_value=1.0):
|
188
|
-
super().__init__(name, limits, read_value)
|
187
|
+
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
|
188
|
+
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
|
189
189
|
|
190
190
|
|
191
191
|
class Device(FakeDevice):
|
@@ -0,0 +1,13 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from qtpy.QtCore import Signal
|
4
|
+
from qtpy.QtGui import QMouseEvent
|
5
|
+
from qtpy.QtWidgets import QLabel
|
6
|
+
|
7
|
+
|
8
|
+
class ClickableLabel(QLabel):
|
9
|
+
clicked = Signal()
|
10
|
+
|
11
|
+
def mouseReleaseEvent(self, ev: QMouseEvent) -> None:
|
12
|
+
self.clicked.emit()
|
13
|
+
return super().mouseReleaseEvent(ev)
|
bec_widgets/utils/colors.py
CHANGED
@@ -15,12 +15,15 @@ if TYPE_CHECKING: # pragma: no cover
|
|
15
15
|
from bec_qthemes._main import AccentColors
|
16
16
|
|
17
17
|
|
18
|
-
def
|
18
|
+
def get_theme_name():
|
19
19
|
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
20
|
-
|
20
|
+
return "dark"
|
21
21
|
else:
|
22
|
-
|
23
|
-
|
22
|
+
return QApplication.instance().theme.theme
|
23
|
+
|
24
|
+
|
25
|
+
def get_theme_palette():
|
26
|
+
return bec_qthemes.load_palette(get_theme_name())
|
24
27
|
|
25
28
|
|
26
29
|
def get_accent_colors() -> AccentColors | None:
|
bec_widgets/utils/crosshair.py
CHANGED
@@ -312,7 +312,7 @@ class Crosshair(QObject):
|
|
312
312
|
y_values[name] = closest_y
|
313
313
|
x_values[name] = closest_x
|
314
314
|
elif isinstance(item, pg.ImageItem): # 2D plot
|
315
|
-
name = item.
|
315
|
+
name = item.objectName() or str(id(item))
|
316
316
|
image_2d = item.image
|
317
317
|
if image_2d is None:
|
318
318
|
continue
|
@@ -400,7 +400,7 @@ class Crosshair(QObject):
|
|
400
400
|
)
|
401
401
|
self.coordinatesChanged1D.emit(coordinate_to_emit)
|
402
402
|
elif isinstance(item, pg.ImageItem):
|
403
|
-
name = item.
|
403
|
+
name = item.objectName() or str(id(item))
|
404
404
|
x, y = x_snap_values[name], y_snap_values[name]
|
405
405
|
if x is None or y is None:
|
406
406
|
continue
|
@@ -1,7 +1,9 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
from bec_qthemes import material_icon
|
4
|
+
from qtpy.QtCore import Signal
|
4
5
|
from qtpy.QtWidgets import (
|
6
|
+
QApplication,
|
5
7
|
QFrame,
|
6
8
|
QHBoxLayout,
|
7
9
|
QLabel,
|
@@ -12,15 +14,20 @@ from qtpy.QtWidgets import (
|
|
12
14
|
QWidget,
|
13
15
|
)
|
14
16
|
|
17
|
+
from bec_widgets.utils.clickable_label import ClickableLabel
|
15
18
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
16
19
|
|
17
20
|
|
18
21
|
class ExpandableGroupFrame(QFrame):
|
19
22
|
|
23
|
+
expansion_state_changed = Signal()
|
24
|
+
|
20
25
|
EXPANDED_ICON_NAME: str = "collapse_all"
|
21
26
|
COLLAPSED_ICON_NAME: str = "expand_all"
|
22
27
|
|
23
|
-
def __init__(
|
28
|
+
def __init__(
|
29
|
+
self, parent: QWidget | None = None, title: str = "", expanded: bool = True, icon: str = ""
|
30
|
+
) -> None:
|
24
31
|
super().__init__(parent=parent)
|
25
32
|
self._expanded = expanded
|
26
33
|
|
@@ -29,19 +36,28 @@ class ExpandableGroupFrame(QFrame):
|
|
29
36
|
self._layout = QVBoxLayout()
|
30
37
|
self._layout.setContentsMargins(0, 0, 0, 0)
|
31
38
|
self.setLayout(self._layout)
|
39
|
+
|
32
40
|
self._title_layout = QHBoxLayout()
|
33
41
|
self._layout.addLayout(self._title_layout)
|
34
|
-
|
35
|
-
self.
|
36
|
-
self.
|
37
|
-
self._title_layout.addWidget(self.
|
42
|
+
|
43
|
+
self._title = ClickableLabel(f"<b>{title}</b>")
|
44
|
+
self._title_icon = ClickableLabel()
|
45
|
+
self._title_layout.addWidget(self._title_icon)
|
38
46
|
self._title_layout.addWidget(self._title)
|
47
|
+
self.icon_name = icon
|
48
|
+
|
49
|
+
self._title_layout.addStretch(1)
|
50
|
+
|
51
|
+
self._expansion_button = QToolButton()
|
52
|
+
self._update_expansion_icon()
|
53
|
+
self._title_layout.addWidget(self._expansion_button, stretch=1)
|
39
54
|
|
40
55
|
self._contents = QWidget(self)
|
41
56
|
self._layout.addWidget(self._contents)
|
42
57
|
|
43
58
|
self._expansion_button.clicked.connect(self.switch_expanded_state)
|
44
59
|
self.expanded = self._expanded # type: ignore
|
60
|
+
self.expansion_state_changed.emit()
|
45
61
|
|
46
62
|
def set_layout(self, layout: QLayout) -> None:
|
47
63
|
self._contents.setLayout(layout)
|
@@ -50,7 +66,8 @@ class ExpandableGroupFrame(QFrame):
|
|
50
66
|
@SafeSlot()
|
51
67
|
def switch_expanded_state(self):
|
52
68
|
self.expanded = not self.expanded # type: ignore
|
53
|
-
self.
|
69
|
+
self._update_expansion_icon()
|
70
|
+
self.expansion_state_changed.emit()
|
54
71
|
|
55
72
|
@SafeProperty(bool)
|
56
73
|
def expanded(self): # type: ignore
|
@@ -61,8 +78,9 @@ class ExpandableGroupFrame(QFrame):
|
|
61
78
|
self._expanded = expanded
|
62
79
|
self._contents.setVisible(expanded)
|
63
80
|
self.updateGeometry()
|
81
|
+
self.adjustSize()
|
64
82
|
|
65
|
-
def
|
83
|
+
def _update_expansion_icon(self):
|
66
84
|
self._expansion_button.setIcon(
|
67
85
|
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
|
68
86
|
if self.expanded
|
@@ -70,3 +88,36 @@ class ExpandableGroupFrame(QFrame):
|
|
70
88
|
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
|
71
89
|
)
|
72
90
|
)
|
91
|
+
|
92
|
+
@SafeProperty(str)
|
93
|
+
def icon_name(self): # type: ignore
|
94
|
+
return self._title_icon_name
|
95
|
+
|
96
|
+
@icon_name.setter
|
97
|
+
def icon_name(self, icon_name: str):
|
98
|
+
self._title_icon_name = icon_name
|
99
|
+
self._set_title_icon(self._title_icon_name)
|
100
|
+
|
101
|
+
def _set_title_icon(self, icon_name: str):
|
102
|
+
if icon_name:
|
103
|
+
self._title_icon.setVisible(True)
|
104
|
+
self._title_icon.setPixmap(
|
105
|
+
material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=True)
|
106
|
+
)
|
107
|
+
else:
|
108
|
+
self._title_icon.setVisible(False)
|
109
|
+
|
110
|
+
|
111
|
+
# Application example
|
112
|
+
if __name__ == "__main__": # pragma: no cover
|
113
|
+
|
114
|
+
app = QApplication([])
|
115
|
+
frame = ExpandableGroupFrame()
|
116
|
+
layout = QVBoxLayout()
|
117
|
+
frame.set_layout(layout)
|
118
|
+
layout.addWidget(QLabel("test1"))
|
119
|
+
layout.addWidget(QLabel("test2"))
|
120
|
+
layout.addWidget(QLabel("test3"))
|
121
|
+
|
122
|
+
frame.show()
|
123
|
+
app.exec()
|
@@ -2,70 +2,99 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
from decimal import Decimal
|
4
4
|
from types import NoneType
|
5
|
+
from typing import NamedTuple
|
5
6
|
|
6
7
|
from bec_lib.logger import bec_logger
|
7
8
|
from bec_qthemes import material_icon
|
8
9
|
from pydantic import BaseModel, ValidationError
|
9
10
|
from qtpy.QtCore import Signal # type: ignore
|
10
|
-
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QVBoxLayout, QWidget
|
11
|
+
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QSizePolicy, QVBoxLayout, QWidget
|
11
12
|
|
12
13
|
from bec_widgets.utils.bec_widget import BECWidget
|
13
14
|
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
14
|
-
from bec_widgets.utils.
|
15
|
+
from bec_widgets.utils.error_popups import SafeProperty
|
16
|
+
from bec_widgets.utils.forms_from_types.items import (
|
17
|
+
DynamicFormItem,
|
18
|
+
DynamicFormItemType,
|
19
|
+
FormItemSpec,
|
20
|
+
widget_from_type,
|
21
|
+
)
|
15
22
|
|
16
23
|
logger = bec_logger.logger
|
17
24
|
|
18
25
|
|
26
|
+
class GridRow(NamedTuple):
|
27
|
+
i: int
|
28
|
+
label: QLabel
|
29
|
+
widget: DynamicFormItem
|
30
|
+
|
31
|
+
|
19
32
|
class TypedForm(BECWidget, QWidget):
|
20
33
|
PLUGIN = True
|
21
34
|
ICON_NAME = "list_alt"
|
22
35
|
|
23
36
|
value_changed = Signal()
|
24
37
|
|
25
|
-
RPC =
|
38
|
+
RPC = True
|
39
|
+
USER_ACCESS = ["enabled", "enabled.setter"]
|
26
40
|
|
27
41
|
def __init__(
|
28
42
|
self,
|
29
43
|
parent=None,
|
30
44
|
items: list[tuple[str, type]] | None = None,
|
31
45
|
form_item_specs: list[FormItemSpec] | None = None,
|
46
|
+
enabled: bool = True,
|
47
|
+
pretty_display: bool = False,
|
32
48
|
client=None,
|
33
49
|
**kwargs,
|
34
50
|
):
|
35
51
|
"""Widget with a list of form items based on a list of types.
|
36
52
|
|
37
53
|
Args:
|
38
|
-
items (list[tuple[str, type]]):
|
39
|
-
|
40
|
-
form_item_specs (list[FormItemSpec]):
|
41
|
-
|
42
|
-
|
43
|
-
|
54
|
+
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
|
55
|
+
Should be a type supported by the logic in items.py
|
56
|
+
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
|
57
|
+
only one of items or form_item_specs should be
|
58
|
+
supplied.
|
59
|
+
enabled (bool, optional): whether fields are enabled for editing.
|
60
|
+
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.
|
44
61
|
"""
|
45
|
-
if
|
46
|
-
|
47
|
-
|
48
|
-
|
62
|
+
if items is not None and form_item_specs is not None:
|
63
|
+
logger.error(
|
64
|
+
"Must specify one and only one of items and form_item_specs! Ignoring `items`."
|
65
|
+
)
|
66
|
+
items = None
|
67
|
+
if items is None and form_item_specs is None:
|
68
|
+
logger.error("Must specify one and only one of items and form_item_specs!")
|
69
|
+
items = []
|
49
70
|
super().__init__(parent=parent, client=client, **kwargs)
|
50
71
|
self._items = (
|
51
72
|
form_item_specs
|
52
73
|
if form_item_specs is not None
|
53
74
|
else [
|
54
|
-
FormItemSpec(name=name, item_type=item_type)
|
75
|
+
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
|
55
76
|
for name, item_type in items # type: ignore
|
56
77
|
]
|
57
78
|
)
|
79
|
+
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
58
80
|
self._layout = QVBoxLayout()
|
59
81
|
self._layout.setContentsMargins(0, 0, 0, 0)
|
60
82
|
self.setLayout(self._layout)
|
61
83
|
|
84
|
+
self._enabled: bool = enabled
|
85
|
+
|
62
86
|
self._form_grid_container = QWidget(parent=self)
|
87
|
+
self._form_grid_container.setSizePolicy(
|
88
|
+
QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding
|
89
|
+
)
|
63
90
|
self._form_grid = QWidget(parent=self._form_grid_container)
|
91
|
+
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
64
92
|
self._layout.addWidget(self._form_grid_container)
|
65
93
|
self._form_grid_container.setLayout(QVBoxLayout())
|
66
94
|
self._form_grid.setLayout(self._new_grid_layout())
|
67
95
|
|
68
96
|
self.populate()
|
97
|
+
self.enabled = self._enabled # type: ignore # QProperty
|
69
98
|
|
70
99
|
def populate(self):
|
71
100
|
self._clear_grid()
|
@@ -80,17 +109,20 @@ class TypedForm(BECWidget, QWidget):
|
|
80
109
|
grid.addWidget(label, row, 0)
|
81
110
|
widget = widget_from_type(item.item_type)(parent=self, spec=item)
|
82
111
|
widget.valueChanged.connect(self.value_changed)
|
112
|
+
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
83
113
|
grid.addWidget(widget, row, 1)
|
84
114
|
|
85
|
-
def
|
115
|
+
def enumerate_form_widgets(self):
|
116
|
+
"""Return a generator over the rows of the form, with the row number, the label widget (to
|
117
|
+
which the field name is attached as a property), and the entry widget"""
|
86
118
|
grid: QGridLayout = self._form_grid.layout() # type: ignore
|
119
|
+
for i in range(grid.rowCount()):
|
120
|
+
yield GridRow(i, grid.itemAtPosition(i, 0).widget(), grid.itemAtPosition(i, 1).widget())
|
121
|
+
|
122
|
+
def _dict_from_grid(self) -> dict[str, DynamicFormItemType]:
|
87
123
|
return {
|
88
|
-
|
89
|
-
.
|
90
|
-
.property("_model_field_name"): grid.itemAtPosition(i, 1)
|
91
|
-
.widget()
|
92
|
-
.getValue() # type: ignore # we only add 'DynamicFormItem's here
|
93
|
-
for i in range(grid.rowCount())
|
124
|
+
row.label.property("_model_field_name"): row.widget.getValue()
|
125
|
+
for row in self.enumerate_form_widgets()
|
94
126
|
}
|
95
127
|
|
96
128
|
def _clear_grid(self):
|
@@ -103,10 +135,13 @@ class TypedForm(BECWidget, QWidget):
|
|
103
135
|
old_layout.deleteLater()
|
104
136
|
self._form_grid.deleteLater()
|
105
137
|
self._form_grid = QWidget()
|
106
|
-
|
138
|
+
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
107
139
|
self._form_grid.setLayout(self._new_grid_layout())
|
108
140
|
self._form_grid_container.layout().addWidget(self._form_grid)
|
109
141
|
|
142
|
+
self.update_size()
|
143
|
+
|
144
|
+
def update_size(self):
|
110
145
|
self._form_grid.adjustSize()
|
111
146
|
self._form_grid_container.adjustSize()
|
112
147
|
self.adjustSize()
|
@@ -114,23 +149,52 @@ class TypedForm(BECWidget, QWidget):
|
|
114
149
|
def _new_grid_layout(self):
|
115
150
|
new_grid = QGridLayout()
|
116
151
|
new_grid.setContentsMargins(0, 0, 0, 0)
|
117
|
-
new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
|
118
152
|
return new_grid
|
119
153
|
|
154
|
+
@property
|
155
|
+
def widget_dict(self):
|
156
|
+
return {
|
157
|
+
row.label.property("_model_field_name"): row.widget
|
158
|
+
for row in self.enumerate_form_widgets()
|
159
|
+
}
|
160
|
+
|
161
|
+
@SafeProperty(bool)
|
162
|
+
def enabled(self):
|
163
|
+
return self._enabled
|
164
|
+
|
165
|
+
@enabled.setter
|
166
|
+
def enabled(self, value: bool):
|
167
|
+
self._enabled = value
|
168
|
+
self.setEnabled(value)
|
169
|
+
|
120
170
|
|
121
171
|
class PydanticModelForm(TypedForm):
|
122
172
|
metadata_updated = Signal(dict)
|
123
173
|
metadata_cleared = Signal(NoneType)
|
124
174
|
|
125
|
-
def __init__(
|
175
|
+
def __init__(
|
176
|
+
self,
|
177
|
+
parent=None,
|
178
|
+
data_model: type[BaseModel] | None = None,
|
179
|
+
enabled: bool = True,
|
180
|
+
pretty_display: bool = False,
|
181
|
+
client=None,
|
182
|
+
**kwargs,
|
183
|
+
):
|
126
184
|
"""
|
127
185
|
A form generated from a pydantic model.
|
128
186
|
|
129
187
|
Args:
|
130
|
-
|
188
|
+
data_model (type[BaseModel]): the model class for which to generate a form.
|
189
|
+
enabled (bool): whether fields are enabled for editing.
|
190
|
+
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.
|
191
|
+
|
131
192
|
"""
|
132
|
-
self.
|
133
|
-
|
193
|
+
self._pretty_display = pretty_display
|
194
|
+
self._md_schema = data_model
|
195
|
+
super().__init__(
|
196
|
+
parent=parent, form_item_specs=self._form_item_specs(), enabled=enabled, client=client
|
197
|
+
)
|
134
198
|
|
135
199
|
self._validity = CompactPopupWidget()
|
136
200
|
self._validity.compact_view = True # type: ignore
|
@@ -147,9 +211,24 @@ class PydanticModelForm(TypedForm):
|
|
147
211
|
self._md_schema = schema
|
148
212
|
self.populate()
|
149
213
|
|
214
|
+
def set_data(self, data: BaseModel):
|
215
|
+
"""Fill the data for the form.
|
216
|
+
|
217
|
+
Args:
|
218
|
+
data (BaseModel): the data to enter into the form. Must be the same type as the
|
219
|
+
currently set schema, raises TypeError otherwise."""
|
220
|
+
if not self._md_schema:
|
221
|
+
raise ValueError("Schema not set - can't set data")
|
222
|
+
if not isinstance(data, self._md_schema):
|
223
|
+
raise TypeError(f"Supplied data {data} not of type {self._md_schema}")
|
224
|
+
for form_item in self.enumerate_form_widgets():
|
225
|
+
form_item.widget.setValue(getattr(data, form_item.label.property("_model_field_name")))
|
226
|
+
|
150
227
|
def _form_item_specs(self):
|
151
228
|
return [
|
152
|
-
FormItemSpec(
|
229
|
+
FormItemSpec(
|
230
|
+
name=name, info=info, item_type=info.annotation, pretty_display=self._pretty_display
|
231
|
+
)
|
153
232
|
for name, info in self._md_schema.model_fields.items()
|
154
233
|
]
|
155
234
|
|
@@ -2,12 +2,12 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
from abc import abstractmethod
|
4
4
|
from decimal import Decimal
|
5
|
-
from types import UnionType
|
6
|
-
from typing import
|
5
|
+
from types import GenericAlias, UnionType
|
6
|
+
from typing import Literal
|
7
7
|
|
8
8
|
from bec_lib.logger import bec_logger
|
9
9
|
from bec_qthemes import material_icon
|
10
|
-
from pydantic import BaseModel, ConfigDict, Field
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
11
11
|
from pydantic.fields import FieldInfo
|
12
12
|
from qtpy.QtCore import Signal # type: ignore
|
13
13
|
from qtpy.QtWidgets import (
|
@@ -21,11 +21,13 @@ from qtpy.QtWidgets import (
|
|
21
21
|
QLayout,
|
22
22
|
QLineEdit,
|
23
23
|
QRadioButton,
|
24
|
+
QSizePolicy,
|
24
25
|
QSpinBox,
|
25
26
|
QToolButton,
|
26
27
|
QWidget,
|
27
28
|
)
|
28
29
|
|
30
|
+
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
|
29
31
|
from bec_widgets.widgets.editors.scan_metadata._util import (
|
30
32
|
clearable_required,
|
31
33
|
field_default,
|
@@ -46,9 +48,36 @@ class FormItemSpec(BaseModel):
|
|
46
48
|
"""
|
47
49
|
|
48
50
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
49
|
-
|
51
|
+
|
52
|
+
item_type: type | UnionType | GenericAlias
|
50
53
|
name: str
|
51
54
|
info: FieldInfo = FieldInfo()
|
55
|
+
pretty_display: bool = Field(
|
56
|
+
default=False,
|
57
|
+
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.",
|
58
|
+
)
|
59
|
+
|
60
|
+
@field_validator("item_type", mode="before")
|
61
|
+
@classmethod
|
62
|
+
def _validate_type(cls, v):
|
63
|
+
allowed_primitives = [str, int, float, bool]
|
64
|
+
if isinstance(v, (type, UnionType)):
|
65
|
+
return v
|
66
|
+
if isinstance(v, GenericAlias):
|
67
|
+
if v.__origin__ in [list, dict] and all(
|
68
|
+
arg in allowed_primitives for arg in v.__args__
|
69
|
+
):
|
70
|
+
return v
|
71
|
+
raise ValueError(
|
72
|
+
f"Generics of type {v} are not supported - only lists and dicts of primitive types {allowed_primitives}"
|
73
|
+
)
|
74
|
+
if type(v) is type(Literal[""]): # _LiteralGenericAlias is not exported from typing
|
75
|
+
arg_types = set(type(arg) for arg in v.__args__)
|
76
|
+
if len(arg_types) != 1:
|
77
|
+
raise ValueError("Mixtures of literal types are not supported!")
|
78
|
+
if (t := arg_types.pop()) in allowed_primitives:
|
79
|
+
return t
|
80
|
+
raise ValueError(f"Literals of type {t} are not supported")
|
52
81
|
|
53
82
|
|
54
83
|
class ClearableBoolEntry(QWidget):
|
@@ -94,10 +123,20 @@ class ClearableBoolEntry(QWidget):
|
|
94
123
|
self._false.setToolTip(tooltip)
|
95
124
|
|
96
125
|
|
126
|
+
DynamicFormItemType = str | int | float | Decimal | bool | dict
|
127
|
+
|
128
|
+
|
97
129
|
class DynamicFormItem(QWidget):
|
98
130
|
valueChanged = Signal()
|
99
131
|
|
100
132
|
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
133
|
+
"""
|
134
|
+
Initializes the form item widget.
|
135
|
+
|
136
|
+
Args:
|
137
|
+
parent (QWidget | None, optional): The parent widget. Defaults to None.
|
138
|
+
spec (FormItemSpec): The specification for the form item.
|
139
|
+
"""
|
101
140
|
super().__init__(parent)
|
102
141
|
self._spec = spec
|
103
142
|
self._layout = QHBoxLayout()
|
@@ -107,11 +146,16 @@ class DynamicFormItem(QWidget):
|
|
107
146
|
self._desc = self._spec.info.description
|
108
147
|
self.setLayout(self._layout)
|
109
148
|
self._add_main_widget()
|
110
|
-
|
111
|
-
|
149
|
+
self._main_widget: QWidget
|
150
|
+
self._main_widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
151
|
+
if not spec.pretty_display:
|
152
|
+
if clearable_required(spec.info):
|
153
|
+
self._add_clear_button()
|
154
|
+
else:
|
155
|
+
self._set_pretty_display()
|
112
156
|
|
113
157
|
@abstractmethod
|
114
|
-
def getValue(self): ...
|
158
|
+
def getValue(self) -> DynamicFormItemType: ...
|
115
159
|
|
116
160
|
@abstractmethod
|
117
161
|
def setValue(self, value): ...
|
@@ -121,6 +165,9 @@ class DynamicFormItem(QWidget):
|
|
121
165
|
"""Add the main data entry widget to self._main_widget and appply any
|
122
166
|
constraints from the field info"""
|
123
167
|
|
168
|
+
def _set_pretty_display(self):
|
169
|
+
self.setEnabled(False)
|
170
|
+
|
124
171
|
def _describe(self, pad=" "):
|
125
172
|
return pad + (self._desc if self._desc else "")
|
126
173
|
|
@@ -164,7 +211,7 @@ class StrMetadataField(DynamicFormItem):
|
|
164
211
|
def setValue(self, value: str):
|
165
212
|
if value is None:
|
166
213
|
self._main_widget.setText("")
|
167
|
-
self._main_widget.setText(value)
|
214
|
+
self._main_widget.setText(str(value))
|
168
215
|
|
169
216
|
|
170
217
|
class IntMetadataField(DynamicFormItem):
|
@@ -202,12 +249,12 @@ class FloatDecimalMetadataField(DynamicFormItem):
|
|
202
249
|
self._main_widget.textChanged.connect(self._value_changed)
|
203
250
|
|
204
251
|
def _add_main_widget(self) -> None:
|
252
|
+
precision = field_precision(self._spec.info)
|
205
253
|
self._main_widget = QDoubleSpinBox()
|
206
254
|
self._layout.addWidget(self._main_widget)
|
207
|
-
min_, max_ = field_limits(self._spec.info,
|
255
|
+
min_, max_ = field_limits(self._spec.info, float, precision)
|
208
256
|
self._main_widget.setMinimum(min_)
|
209
257
|
self._main_widget.setMaximum(max_)
|
210
|
-
precision = field_precision(self._spec.info)
|
211
258
|
if precision:
|
212
259
|
self._main_widget.setDecimals(precision)
|
213
260
|
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
|
@@ -224,10 +271,10 @@ class FloatDecimalMetadataField(DynamicFormItem):
|
|
224
271
|
return self._default
|
225
272
|
return self._main_widget.value()
|
226
273
|
|
227
|
-
def setValue(self, value: float):
|
274
|
+
def setValue(self, value: float | Decimal):
|
228
275
|
if value is None:
|
229
276
|
self._main_widget.clear()
|
230
|
-
self._main_widget.setValue(value)
|
277
|
+
self._main_widget.setValue(float(value))
|
231
278
|
|
232
279
|
|
233
280
|
class BoolMetadataField(DynamicFormItem):
|
@@ -251,6 +298,27 @@ class BoolMetadataField(DynamicFormItem):
|
|
251
298
|
self._main_widget.setChecked(value)
|
252
299
|
|
253
300
|
|
301
|
+
class DictMetadataField(DynamicFormItem):
|
302
|
+
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
303
|
+
super().__init__(parent=parent, spec=spec)
|
304
|
+
self._main_widget.data_changed.connect(self._value_changed)
|
305
|
+
|
306
|
+
def _set_pretty_display(self):
|
307
|
+
self._main_widget.set_button_visibility(False)
|
308
|
+
super()._set_pretty_display()
|
309
|
+
|
310
|
+
def _add_main_widget(self) -> None:
|
311
|
+
self._main_widget = DictBackedTable(self, [])
|
312
|
+
self._layout.addWidget(self._main_widget)
|
313
|
+
self._main_widget.setToolTip(self._describe(""))
|
314
|
+
|
315
|
+
def getValue(self):
|
316
|
+
return self._main_widget.dump_dict()
|
317
|
+
|
318
|
+
def setValue(self, value):
|
319
|
+
self._main_widget.replace_data(value)
|
320
|
+
|
321
|
+
|
254
322
|
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
|
255
323
|
if annotation in [str, str | None]:
|
256
324
|
return StrMetadataField
|
@@ -260,6 +328,14 @@ def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormIte
|
|
260
328
|
return FloatDecimalMetadataField
|
261
329
|
if annotation in [bool, bool | None]:
|
262
330
|
return BoolMetadataField
|
331
|
+
if annotation in [dict, dict | None] or (
|
332
|
+
isinstance(annotation, GenericAlias) and annotation.__origin__ is dict
|
333
|
+
):
|
334
|
+
return DictMetadataField
|
335
|
+
if annotation in [list, list | None] or (
|
336
|
+
isinstance(annotation, GenericAlias) and annotation.__origin__ is list
|
337
|
+
):
|
338
|
+
return StrMetadataField
|
263
339
|
else:
|
264
340
|
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
|
265
341
|
return StrMetadataField
|