bec-widgets 2.15.1__py3-none-any.whl → 2.16.0__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 +61 -0
- PKG-INFO +1 -1
- bec_widgets/tests/utils.py +2 -2
- bec_widgets/utils/expandable_frame.py +12 -7
- bec_widgets/utils/forms_from_types/forms.py +40 -22
- bec_widgets/utils/forms_from_types/items.py +282 -32
- bec_widgets/widgets/editors/dict_backed_table.py +69 -9
- bec_widgets/widgets/editors/scan_metadata/_util.py +3 -1
- bec_widgets/widgets/services/device_browser/device_browser.py +5 -6
- bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py +254 -0
- bec_widgets/widgets/services/device_browser/device_item/device_config_form.py +60 -0
- bec_widgets/widgets/services/device_browser/device_item/device_item.py +52 -54
- bec_widgets/widgets/utility/toggle/toggle.py +9 -0
- {bec_widgets-2.15.1.dist-info → bec_widgets-2.16.0.dist-info}/METADATA +1 -1
- {bec_widgets-2.15.1.dist-info → bec_widgets-2.16.0.dist-info}/RECORD +19 -17
- pyproject.toml +1 -1
- {bec_widgets-2.15.1.dist-info → bec_widgets-2.16.0.dist-info}/WHEEL +0 -0
- {bec_widgets-2.15.1.dist-info → bec_widgets-2.16.0.dist-info}/entry_points.txt +0 -0
- {bec_widgets-2.15.1.dist-info → bec_widgets-2.16.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,32 +1,43 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import typing
|
3
4
|
from abc import abstractmethod
|
4
5
|
from decimal import Decimal
|
5
6
|
from types import GenericAlias, UnionType
|
6
|
-
from typing import Literal
|
7
|
+
from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args
|
7
8
|
|
8
9
|
from bec_lib.logger import bec_logger
|
9
10
|
from bec_qthemes import material_icon
|
10
11
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
11
12
|
from pydantic.fields import FieldInfo
|
12
|
-
from
|
13
|
+
from pydantic_core import PydanticUndefined
|
14
|
+
from qtpy import QtCore
|
15
|
+
from qtpy.QtCore import QSize, Qt, Signal # type: ignore
|
16
|
+
from qtpy.QtGui import QFontMetrics
|
13
17
|
from qtpy.QtWidgets import (
|
14
18
|
QApplication,
|
15
19
|
QButtonGroup,
|
16
20
|
QCheckBox,
|
21
|
+
QComboBox,
|
17
22
|
QDoubleSpinBox,
|
18
23
|
QGridLayout,
|
19
24
|
QHBoxLayout,
|
20
25
|
QLabel,
|
21
26
|
QLayout,
|
22
27
|
QLineEdit,
|
28
|
+
QListWidget,
|
29
|
+
QListWidgetItem,
|
30
|
+
QPushButton,
|
23
31
|
QRadioButton,
|
24
32
|
QSizePolicy,
|
25
33
|
QSpinBox,
|
26
34
|
QToolButton,
|
35
|
+
QVBoxLayout,
|
27
36
|
QWidget,
|
28
37
|
)
|
29
38
|
|
39
|
+
from bec_widgets.utils.error_popups import SafeSlot
|
40
|
+
from bec_widgets.utils.widget_io import WidgetIO
|
30
41
|
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
|
31
42
|
from bec_widgets.widgets.editors.scan_metadata._util import (
|
32
43
|
clearable_required,
|
@@ -36,6 +47,7 @@ from bec_widgets.widgets.editors.scan_metadata._util import (
|
|
36
47
|
field_minlen,
|
37
48
|
field_precision,
|
38
49
|
)
|
50
|
+
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
39
51
|
|
40
52
|
logger = bec_logger.logger
|
41
53
|
|
@@ -123,7 +135,7 @@ class ClearableBoolEntry(QWidget):
|
|
123
135
|
self._false.setToolTip(tooltip)
|
124
136
|
|
125
137
|
|
126
|
-
DynamicFormItemType = str | int | float | Decimal | bool | dict
|
138
|
+
DynamicFormItemType = str | int | float | Decimal | bool | dict | list | None
|
127
139
|
|
128
140
|
|
129
141
|
class DynamicFormItem(QWidget):
|
@@ -146,8 +158,9 @@ class DynamicFormItem(QWidget):
|
|
146
158
|
self._desc = self._spec.info.description
|
147
159
|
self.setLayout(self._layout)
|
148
160
|
self._add_main_widget()
|
149
|
-
self._main_widget:
|
150
|
-
self._main_widget.setSizePolicy(QSizePolicy.
|
161
|
+
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
|
162
|
+
self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
163
|
+
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
151
164
|
if not spec.pretty_display:
|
152
165
|
if clearable_required(spec.info):
|
153
166
|
self._add_clear_button()
|
@@ -167,6 +180,8 @@ class DynamicFormItem(QWidget):
|
|
167
180
|
|
168
181
|
def _set_pretty_display(self):
|
169
182
|
self.setEnabled(False)
|
183
|
+
if button := getattr(self, "_clear_button", None):
|
184
|
+
button.setVisible(False)
|
170
185
|
|
171
186
|
def _describe(self, pad=" "):
|
172
187
|
return pad + (self._desc if self._desc else "")
|
@@ -185,7 +200,7 @@ class DynamicFormItem(QWidget):
|
|
185
200
|
self.valueChanged.emit()
|
186
201
|
|
187
202
|
|
188
|
-
class
|
203
|
+
class StrFormItem(DynamicFormItem):
|
189
204
|
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
190
205
|
super().__init__(parent=parent, spec=spec)
|
191
206
|
self._main_widget.textChanged.connect(self._value_changed)
|
@@ -210,11 +225,11 @@ class StrMetadataField(DynamicFormItem):
|
|
210
225
|
|
211
226
|
def setValue(self, value: str):
|
212
227
|
if value is None:
|
213
|
-
self._main_widget.setText("")
|
228
|
+
return self._main_widget.setText("")
|
214
229
|
self._main_widget.setText(str(value))
|
215
230
|
|
216
231
|
|
217
|
-
class
|
232
|
+
class IntFormItem(DynamicFormItem):
|
218
233
|
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
219
234
|
super().__init__(parent=parent, spec=spec)
|
220
235
|
self._main_widget.textChanged.connect(self._value_changed)
|
@@ -243,7 +258,7 @@ class IntMetadataField(DynamicFormItem):
|
|
243
258
|
self._main_widget.setValue(value)
|
244
259
|
|
245
260
|
|
246
|
-
class
|
261
|
+
class FloatDecimalFormItem(DynamicFormItem):
|
247
262
|
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
248
263
|
super().__init__(parent=parent, spec=spec)
|
249
264
|
self._main_widget.textChanged.connect(self._value_changed)
|
@@ -277,7 +292,7 @@ class FloatDecimalMetadataField(DynamicFormItem):
|
|
277
292
|
self._main_widget.setValue(float(value))
|
278
293
|
|
279
294
|
|
280
|
-
class
|
295
|
+
class BoolFormItem(DynamicFormItem):
|
281
296
|
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
282
297
|
super().__init__(parent=parent, spec=spec)
|
283
298
|
self._main_widget.stateChanged.connect(self._value_changed)
|
@@ -298,10 +313,26 @@ class BoolMetadataField(DynamicFormItem):
|
|
298
313
|
self._main_widget.setChecked(value)
|
299
314
|
|
300
315
|
|
301
|
-
class
|
316
|
+
class BoolToggleFormItem(BoolFormItem):
|
317
|
+
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
318
|
+
if spec.info.default is PydanticUndefined:
|
319
|
+
spec.info.default = False
|
320
|
+
super().__init__(parent=parent, spec=spec)
|
321
|
+
|
322
|
+
def _add_main_widget(self) -> None:
|
323
|
+
self._main_widget = ToggleSwitch()
|
324
|
+
self._layout.addWidget(self._main_widget)
|
325
|
+
self._main_widget.setToolTip(self._describe(""))
|
326
|
+
if self._default is not None:
|
327
|
+
self._main_widget.setChecked(self._default)
|
328
|
+
|
329
|
+
|
330
|
+
class DictFormItem(DynamicFormItem):
|
302
331
|
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
303
332
|
super().__init__(parent=parent, spec=spec)
|
304
333
|
self._main_widget.data_changed.connect(self._value_changed)
|
334
|
+
if spec.info.default is not PydanticUndefined:
|
335
|
+
self._main_widget.set_default(spec.info.default)
|
305
336
|
|
306
337
|
def _set_pretty_display(self):
|
307
338
|
self._main_widget.set_button_visibility(False)
|
@@ -319,44 +350,263 @@ class DictMetadataField(DynamicFormItem):
|
|
319
350
|
self._main_widget.replace_data(value)
|
320
351
|
|
321
352
|
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
353
|
+
class _ItemAndWidgetType(NamedTuple):
|
354
|
+
# TODO: this should be generic but not supported in 3.10
|
355
|
+
item: type[int | float | str]
|
356
|
+
widget: type[QWidget]
|
357
|
+
default: int | float | str
|
358
|
+
|
359
|
+
|
360
|
+
class ListFormItem(DynamicFormItem):
|
361
|
+
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
362
|
+
if spec.info.annotation is list:
|
363
|
+
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
364
|
+
elif isinstance(spec.info.annotation, GenericAlias):
|
365
|
+
args = set(typing.get_args(spec.info.annotation))
|
366
|
+
if args == {str}:
|
367
|
+
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
368
|
+
if args == {int}:
|
369
|
+
self._types = _ItemAndWidgetType(int, QSpinBox, 0)
|
370
|
+
if args == {float} or args == {int, float}:
|
371
|
+
self._types = _ItemAndWidgetType(float, QDoubleSpinBox, 0.0)
|
372
|
+
else:
|
373
|
+
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
374
|
+
super().__init__(parent=parent, spec=spec)
|
375
|
+
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
376
|
+
self._main_widget: QListWidget
|
377
|
+
self._data = []
|
378
|
+
self._min_lines = 2 if spec.pretty_display else 4
|
379
|
+
self._repop(self._data)
|
380
|
+
|
381
|
+
def sizeHint(self):
|
382
|
+
default = super().sizeHint()
|
383
|
+
return QSize(default.width(), QFontMetrics(self.font()).height() * 6)
|
384
|
+
|
385
|
+
def _add_main_widget(self) -> None:
|
386
|
+
self._main_widget = QListWidget()
|
387
|
+
self._layout.addWidget(self._main_widget)
|
388
|
+
self._layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
389
|
+
self._add_buttons()
|
390
|
+
|
391
|
+
def _add_buttons(self):
|
392
|
+
self._button_holder = QWidget()
|
393
|
+
self._buttons = QVBoxLayout()
|
394
|
+
self._button_holder.setLayout(self._buttons)
|
395
|
+
self._layout.addWidget(self._button_holder)
|
396
|
+
self._add_button = QPushButton("+")
|
397
|
+
self._add_button.setToolTip("add a new row")
|
398
|
+
self._remove_button = QPushButton("-")
|
399
|
+
self._remove_button.setToolTip("delete the focused row (if any)")
|
400
|
+
self._add_button.clicked.connect(self._add_row)
|
401
|
+
self._remove_button.clicked.connect(self._delete_row)
|
402
|
+
self._buttons.addWidget(self._add_button)
|
403
|
+
self._buttons.addWidget(self._remove_button)
|
404
|
+
|
405
|
+
def _set_pretty_display(self):
|
406
|
+
super()._set_pretty_display()
|
407
|
+
self._button_holder.setHidden(True)
|
408
|
+
|
409
|
+
def _repop(self, data):
|
410
|
+
self._main_widget.clear()
|
411
|
+
for val in data:
|
412
|
+
self._add_list_item(val)
|
413
|
+
self.scale_to_data()
|
414
|
+
|
415
|
+
def _add_data_item(self, val=None):
|
416
|
+
val = val or self._types.default
|
417
|
+
self._data.append(val)
|
418
|
+
self._add_list_item(val)
|
419
|
+
self._repop(self._data)
|
420
|
+
|
421
|
+
def _add_list_item(self, val):
|
422
|
+
item = QListWidgetItem(self._main_widget)
|
423
|
+
item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable)
|
424
|
+
item_widget = self._types.widget(parent=self)
|
425
|
+
WidgetIO.set_value(item_widget, val)
|
426
|
+
self._main_widget.setItemWidget(item, item_widget)
|
427
|
+
self._main_widget.addItem(item)
|
428
|
+
WidgetIO.connect_widget_change_signal(item_widget, self._update)
|
429
|
+
return item_widget
|
430
|
+
|
431
|
+
def _update(self, _, value, *args):
|
432
|
+
self._data[self._main_widget.currentRow()] = value
|
433
|
+
|
434
|
+
@SafeSlot()
|
435
|
+
def _add_row(self):
|
436
|
+
self._add_data_item(self._types.default)
|
437
|
+
self._repop(self._data)
|
438
|
+
|
439
|
+
@SafeSlot()
|
440
|
+
def _delete_row(self):
|
441
|
+
if selected := self._main_widget.currentItem():
|
442
|
+
self._main_widget.removeItemWidget(selected)
|
443
|
+
row = self._main_widget.currentRow()
|
444
|
+
self._main_widget.takeItem(row)
|
445
|
+
self._data.pop(row)
|
446
|
+
self._repop(self._data)
|
447
|
+
|
448
|
+
@SafeSlot()
|
449
|
+
def clear(self):
|
450
|
+
self._repop([])
|
451
|
+
|
452
|
+
def getValue(self):
|
453
|
+
return self._data
|
454
|
+
|
455
|
+
def setValue(self, value: Iterable):
|
456
|
+
if set(map(type, value)) | {self._types.item} != {self._types.item}:
|
457
|
+
raise ValueError(f"This widget only accepts items of type {self._types.item}")
|
458
|
+
self._data = list(value)
|
459
|
+
self._repop(self._data)
|
460
|
+
|
461
|
+
def _line_height(self):
|
462
|
+
return QFontMetrics(self._main_widget.font()).height()
|
463
|
+
|
464
|
+
def set_max_height_in_lines(self, lines: int):
|
465
|
+
outer_inc = 1 if self._spec.pretty_display else 3
|
466
|
+
self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines))
|
467
|
+
self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1))
|
468
|
+
self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc))
|
469
|
+
|
470
|
+
def scale_to_data(self, *_):
|
471
|
+
self.set_max_height_in_lines(self._main_widget.count() + 1)
|
472
|
+
|
473
|
+
|
474
|
+
class SetFormItem(ListFormItem):
|
475
|
+
def _add_main_widget(self) -> None:
|
476
|
+
super()._add_main_widget()
|
477
|
+
self._add_item_field = self._types.widget()
|
478
|
+
self._buttons.addWidget(QLabel("Add new:"))
|
479
|
+
self._buttons.addWidget(self._add_item_field)
|
480
|
+
self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Minimum)
|
481
|
+
|
482
|
+
@SafeSlot()
|
483
|
+
def _add_row(self):
|
484
|
+
self._add_data_item(WidgetIO.get_value(self._add_item_field))
|
485
|
+
self._repop(self._data)
|
486
|
+
|
487
|
+
def _update(self, _, value, *args):
|
488
|
+
if value in self._data:
|
489
|
+
return
|
490
|
+
return super()._update(_, value, *args)
|
491
|
+
|
492
|
+
def _add_data_item(self, val=None):
|
493
|
+
val = val or self._types.default
|
494
|
+
if val == self._types.default or val in self._data:
|
495
|
+
return
|
496
|
+
self._data.append(val)
|
497
|
+
self._add_list_item(val)
|
498
|
+
|
499
|
+
def _add_list_item(self, val):
|
500
|
+
item_widget = super()._add_list_item(val)
|
501
|
+
if isinstance(item_widget, QLineEdit):
|
502
|
+
item_widget.setReadOnly(True)
|
503
|
+
return item_widget
|
504
|
+
|
505
|
+
def getValue(self):
|
506
|
+
return set(self._data)
|
507
|
+
|
508
|
+
def setValue(self, value: set):
|
509
|
+
return super().setValue(set(value))
|
510
|
+
|
511
|
+
|
512
|
+
class StrLiteralFormItem(DynamicFormItem):
|
513
|
+
def _add_main_widget(self) -> None:
|
514
|
+
self._main_widget = QComboBox()
|
515
|
+
self._options = get_args(self._spec.info.annotation)
|
516
|
+
for opt in self._options:
|
517
|
+
self._main_widget.addItem(opt)
|
518
|
+
self._layout.addWidget(self._main_widget)
|
519
|
+
|
520
|
+
def getValue(self):
|
521
|
+
return self._main_widget.currentText()
|
522
|
+
|
523
|
+
def setValue(self, value: str | None):
|
524
|
+
if value is None:
|
525
|
+
self.clear()
|
526
|
+
for i in range(self._main_widget.count()):
|
527
|
+
if self._main_widget.itemText(i) == value:
|
528
|
+
self._main_widget.setCurrentIndex(i)
|
529
|
+
return
|
530
|
+
raise ValueError(f"Cannot set value: {value}, options are: {self._options}")
|
531
|
+
|
532
|
+
def clear(self):
|
533
|
+
self._main_widget.setCurrentIndex(-1)
|
534
|
+
|
535
|
+
|
536
|
+
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
|
537
|
+
|
538
|
+
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
|
539
|
+
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
|
540
|
+
# and delete/insert keys or change the order
|
541
|
+
"literal_str": (
|
542
|
+
lambda spec: type(spec.info.annotation) is type(Literal[""])
|
543
|
+
and set(type(arg) for arg in get_args(spec.info.annotation)) == {str},
|
544
|
+
StrLiteralFormItem,
|
545
|
+
),
|
546
|
+
"str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem),
|
547
|
+
"int": (lambda spec: spec.item_type in [int, int | None], IntFormItem),
|
548
|
+
"float_decimal": (
|
549
|
+
lambda spec: spec.item_type in [float, float | None, Decimal, Decimal | None],
|
550
|
+
FloatDecimalFormItem,
|
551
|
+
),
|
552
|
+
"bool": (lambda spec: spec.item_type in [bool, bool | None], BoolFormItem),
|
553
|
+
"dict": (
|
554
|
+
lambda spec: spec.item_type in [dict, dict | None]
|
555
|
+
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is dict),
|
556
|
+
DictFormItem,
|
557
|
+
),
|
558
|
+
"list": (
|
559
|
+
lambda spec: spec.item_type in [list, list | None]
|
560
|
+
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is list),
|
561
|
+
ListFormItem,
|
562
|
+
),
|
563
|
+
"set": (
|
564
|
+
lambda spec: spec.item_type in [set, set | None]
|
565
|
+
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is set),
|
566
|
+
SetFormItem,
|
567
|
+
),
|
568
|
+
}
|
569
|
+
|
570
|
+
|
571
|
+
def widget_from_type(
|
572
|
+
spec: FormItemSpec, widget_types: WidgetTypeRegistry | None = None
|
573
|
+
) -> type[DynamicFormItem]:
|
574
|
+
widget_types = widget_types or DEFAULT_WIDGET_TYPES
|
575
|
+
for predicate, widget_type in widget_types.values():
|
576
|
+
if predicate(spec):
|
577
|
+
return widget_type
|
578
|
+
logger.warning(
|
579
|
+
f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation."
|
580
|
+
)
|
581
|
+
return StrFormItem
|
342
582
|
|
343
583
|
|
344
584
|
if __name__ == "__main__": # pragma: no cover
|
345
585
|
|
346
586
|
class TestModel(BaseModel):
|
587
|
+
value0: set = Field(set(["a", "b"]))
|
347
588
|
value1: str | None = Field(None)
|
348
589
|
value2: bool | None = Field(None)
|
349
590
|
value3: bool = Field(True)
|
350
591
|
value4: int = Field(123)
|
351
592
|
value5: int | None = Field()
|
593
|
+
value6: list[int] = Field()
|
594
|
+
value7: list = Field()
|
352
595
|
|
353
596
|
app = QApplication([])
|
354
597
|
w = QWidget()
|
355
598
|
layout = QGridLayout()
|
356
599
|
w.setLayout(layout)
|
600
|
+
items = []
|
357
601
|
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
|
602
|
+
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
|
358
603
|
layout.addWidget(QLabel(field_name), i, 0)
|
359
|
-
|
604
|
+
widg = widget_from_type(spec)(spec=spec)
|
605
|
+
items.append(widg)
|
606
|
+
layout.addWidget(widg, i, 1)
|
607
|
+
|
608
|
+
items[6].setValue([1, 2, 3, 4])
|
609
|
+
items[7].setValue(["1", "2", "asdfg", "qwerty"])
|
360
610
|
|
361
611
|
w.show()
|
362
612
|
app.exec()
|
@@ -4,6 +4,7 @@ from typing import Any
|
|
4
4
|
|
5
5
|
from qtpy import QtWidgets
|
6
6
|
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
|
7
|
+
from qtpy.QtGui import QFontMetrics
|
7
8
|
from qtpy.QtWidgets import (
|
8
9
|
QApplication,
|
9
10
|
QHBoxLayout,
|
@@ -14,7 +15,9 @@ from qtpy.QtWidgets import (
|
|
14
15
|
QWidget,
|
15
16
|
)
|
16
17
|
|
17
|
-
from bec_widgets.utils.error_popups import SafeSlot
|
18
|
+
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
19
|
+
|
20
|
+
_NOT_SET = object()
|
18
21
|
|
19
22
|
|
20
23
|
class DictBackedTableModel(QAbstractTableModel):
|
@@ -26,6 +29,7 @@ class DictBackedTableModel(QAbstractTableModel):
|
|
26
29
|
data (list[list[str]]): list of key-value pairs to initialise with"""
|
27
30
|
super().__init__()
|
28
31
|
self._data: list[list[str]] = data
|
32
|
+
self._default = _NOT_SET
|
29
33
|
self._disallowed_keys: list[str] = []
|
30
34
|
|
31
35
|
# pylint: disable=missing-function-docstring
|
@@ -51,7 +55,10 @@ class DictBackedTableModel(QAbstractTableModel):
|
|
51
55
|
Qt.ItemDataRole.EditRole,
|
52
56
|
Qt.ItemDataRole.ToolTipRole,
|
53
57
|
]:
|
54
|
-
|
58
|
+
try:
|
59
|
+
return str(self._data[index.row()][index.column()])
|
60
|
+
except IndexError:
|
61
|
+
return None
|
55
62
|
|
56
63
|
def setData(self, index, value, role):
|
57
64
|
if role == Qt.ItemDataRole.EditRole:
|
@@ -63,9 +70,10 @@ class DictBackedTableModel(QAbstractTableModel):
|
|
63
70
|
return False
|
64
71
|
|
65
72
|
def replaceData(self, data: dict):
|
73
|
+
self.delete_rows(list(range(len(self._data))))
|
66
74
|
self.resetInternalData()
|
67
|
-
self._data = [[k, v] for k, v in data.items()]
|
68
|
-
self.dataChanged.emit(self.index(0, 0), self.index(len(self._data),
|
75
|
+
self._data = [[str(k), str(v)] for k, v in data.items()]
|
76
|
+
self.dataChanged.emit(self.index(0, 0), self.index(len(self._data), 1))
|
69
77
|
|
70
78
|
def update_disallowed_keys(self, keys: list[str]):
|
71
79
|
"""Set the list of keys which may not be used.
|
@@ -76,7 +84,7 @@ class DictBackedTableModel(QAbstractTableModel):
|
|
76
84
|
for i, item in enumerate(self._data):
|
77
85
|
if item[0] in self._disallowed_keys:
|
78
86
|
self._data[i][0] = ""
|
79
|
-
self.dataChanged.emit(self.index(i, 0), self.index(i,
|
87
|
+
self.dataChanged.emit(self.index(i, 0), self.index(i, 1))
|
80
88
|
|
81
89
|
def _other_keys(self, row: int):
|
82
90
|
return [r[0] for r in self._data[:row] + self._data[row + 1 :]]
|
@@ -105,24 +113,39 @@ class DictBackedTableModel(QAbstractTableModel):
|
|
105
113
|
@SafeSlot()
|
106
114
|
def add_row(self):
|
107
115
|
self.insertRow(self.rowCount())
|
116
|
+
self.dataChanged.emit(self.index(self.rowCount(), 0), self.index(self.rowCount(), 1), 0)
|
108
117
|
|
109
118
|
@SafeSlot(list)
|
110
119
|
def delete_rows(self, rows: list[int]):
|
111
120
|
# delete from the end so indices stay correct
|
112
121
|
for row in sorted(rows, reverse=True):
|
122
|
+
self.dataChanged.emit(self.index(row, 0), self.index(row, 1), 0)
|
113
123
|
self.removeRows(row, 1, QModelIndex())
|
114
124
|
|
125
|
+
def set_default(self, value: dict | None):
|
126
|
+
self._default = value
|
127
|
+
|
115
128
|
def dump_dict(self):
|
116
|
-
if self._data
|
129
|
+
if self._data in [[], [[]], [["", ""]]]:
|
130
|
+
if self._default is not _NOT_SET:
|
131
|
+
return self._default
|
117
132
|
return {}
|
118
133
|
return dict(self._data)
|
119
134
|
|
135
|
+
def length(self):
|
136
|
+
return len(self._data)
|
137
|
+
|
120
138
|
|
121
139
|
class DictBackedTable(QWidget):
|
122
140
|
delete_rows = Signal(list)
|
123
141
|
data_changed = Signal(dict)
|
124
142
|
|
125
|
-
def __init__(
|
143
|
+
def __init__(
|
144
|
+
self,
|
145
|
+
parent: QWidget | None = None,
|
146
|
+
initial_data: list[list[str]] = [],
|
147
|
+
autoscale_to_data: bool = True,
|
148
|
+
):
|
126
149
|
"""Widget which uses a DictBackedTableModel to display an editable table
|
127
150
|
which can be extracted as a dict.
|
128
151
|
|
@@ -133,15 +156,25 @@ class DictBackedTable(QWidget):
|
|
133
156
|
|
134
157
|
self._layout = QHBoxLayout()
|
135
158
|
self.setLayout(self._layout)
|
159
|
+
self._layout.setContentsMargins(0, 0, 0, 0)
|
160
|
+
|
136
161
|
self._table_model = DictBackedTableModel(initial_data)
|
137
162
|
self._table_view = QTreeView()
|
163
|
+
|
138
164
|
self._table_view.setModel(self._table_model)
|
165
|
+
self._min_lines = 3
|
166
|
+
self.set_height_in_lines(len(initial_data))
|
139
167
|
self._table_view.setSizePolicy(
|
140
168
|
QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
141
169
|
)
|
142
170
|
self._table_view.setAlternatingRowColors(True)
|
171
|
+
self._table_view.setUniformRowHeights(True)
|
172
|
+
self._table_view.setWordWrap(False)
|
143
173
|
self._table_view.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
|
144
174
|
self._table_view.header().setSectionResizeMode(5, QtWidgets.QHeaderView.Stretch)
|
175
|
+
self.autoscale = autoscale_to_data
|
176
|
+
if self.autoscale:
|
177
|
+
self.data_changed.connect(self.scale_to_data)
|
145
178
|
self._layout.addWidget(self._table_view)
|
146
179
|
|
147
180
|
self._button_holder = QWidget()
|
@@ -157,8 +190,12 @@ class DictBackedTable(QWidget):
|
|
157
190
|
self._add_button.clicked.connect(self._table_model.add_row)
|
158
191
|
self._remove_button.clicked.connect(self.delete_selected_rows)
|
159
192
|
self.delete_rows.connect(self._table_model.delete_rows)
|
193
|
+
|
160
194
|
self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict()))
|
161
195
|
|
196
|
+
def set_default(self, value: dict | None):
|
197
|
+
self._table_model.set_default(value)
|
198
|
+
|
162
199
|
def set_button_visibility(self, value: bool):
|
163
200
|
self._button_holder.setVisible(value)
|
164
201
|
|
@@ -166,8 +203,8 @@ class DictBackedTable(QWidget):
|
|
166
203
|
def clear(self):
|
167
204
|
self._table_model.replaceData({})
|
168
205
|
|
169
|
-
def replace_data(self, data: dict):
|
170
|
-
self._table_model.replaceData(data)
|
206
|
+
def replace_data(self, data: dict | None):
|
207
|
+
self._table_model.replaceData(data or {})
|
171
208
|
|
172
209
|
def delete_selected_rows(self):
|
173
210
|
"""Delete rows which are part of the selection model"""
|
@@ -187,6 +224,29 @@ class DictBackedTable(QWidget):
|
|
187
224
|
keys (list[str]): list of keys which are forbidden."""
|
188
225
|
self._table_model.update_disallowed_keys(keys)
|
189
226
|
|
227
|
+
def set_height_in_lines(self, lines: int):
|
228
|
+
self._table_view.setMaximumHeight(
|
229
|
+
int(QFontMetrics(self._table_view.font()).height() * max(lines + 2, self._min_lines))
|
230
|
+
)
|
231
|
+
|
232
|
+
@SafeSlot()
|
233
|
+
@SafeSlot(dict)
|
234
|
+
def scale_to_data(self, *_):
|
235
|
+
self.set_height_in_lines(self._table_model.length())
|
236
|
+
|
237
|
+
@SafeProperty(bool)
|
238
|
+
def autoscale(self): # type: ignore
|
239
|
+
return self._autoscale
|
240
|
+
|
241
|
+
@autoscale.setter
|
242
|
+
def autoscale(self, autoscale: bool):
|
243
|
+
self._autoscale = autoscale
|
244
|
+
if self._autoscale:
|
245
|
+
self.scale_to_data()
|
246
|
+
self.data_changed.connect(self.scale_to_data)
|
247
|
+
else:
|
248
|
+
self.data_changed.disconnect(self.scale_to_data)
|
249
|
+
|
190
250
|
|
191
251
|
if __name__ == "__main__": # pragma: no cover
|
192
252
|
from bec_widgets.utils.colors import set_theme
|
@@ -67,4 +67,6 @@ def field_default(info: FieldInfo):
|
|
67
67
|
|
68
68
|
|
69
69
|
def clearable_required(info: FieldInfo):
|
70
|
-
return type(None) in get_args(info.annotation) or
|
70
|
+
return type(None) in get_args(info.annotation) or (
|
71
|
+
info.is_required() and info.default is PydanticUndefined
|
72
|
+
)
|
@@ -87,14 +87,13 @@ class DeviceBrowser(BECWidget, QWidget):
|
|
87
87
|
for device, device_obj in self.dev.items():
|
88
88
|
item = QListWidgetItem(self.dev_list)
|
89
89
|
device_item = DeviceItem(
|
90
|
-
parent=self,
|
90
|
+
parent=self,
|
91
|
+
device=device,
|
92
|
+
devices=self.dev,
|
93
|
+
icon=map_device_type_to_icon(device_obj),
|
91
94
|
)
|
92
|
-
|
93
95
|
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
|
94
|
-
|
95
|
-
device_config = self.dev[device]._config # pylint: disable=protected-access
|
96
|
-
device_item.set_display_config(device_config)
|
97
|
-
tooltip = device_config.get("description", "")
|
96
|
+
tooltip = self.dev[device]._config.get("description", "")
|
98
97
|
device_item.setToolTip(tooltip)
|
99
98
|
device_item.broadcast_size_hint.connect(item.setSizeHint)
|
100
99
|
item.setSizeHint(device_item.sizeHint())
|