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.
@@ -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 qtpy.QtCore import Signal # type: ignore
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: QWidget
150
- self._main_widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
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 StrMetadataField(DynamicFormItem):
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 IntMetadataField(DynamicFormItem):
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 FloatDecimalMetadataField(DynamicFormItem):
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 BoolMetadataField(DynamicFormItem):
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 DictMetadataField(DynamicFormItem):
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
- def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
323
- if annotation in [str, str | None]:
324
- return StrMetadataField
325
- if annotation in [int, int | None]:
326
- return IntMetadataField
327
- if annotation in [float, float | None, Decimal, Decimal | None]:
328
- return FloatDecimalMetadataField
329
- if annotation in [bool, bool | None]:
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
339
- else:
340
- logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
341
- return StrMetadataField
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
- layout.addWidget(widget_from_type(info.annotation)(info), i, 1)
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
- return str(self._data[index.row()][index.column()])
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), 0))
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, 0))
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__(self, parent: QWidget | None = None, initial_data: list[list[str]] = []):
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 info.is_required()
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, device=device, icon=map_device_type_to_icon(device_obj)
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())