bec-widgets 2.15.0__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 +72 -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/containers/main_window/addons/scroll_label.py +24 -3
- bec_widgets/widgets/containers/main_window/main_window.py +32 -1
- 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.0.dist-info → bec_widgets-2.16.0.dist-info}/METADATA +1 -1
- {bec_widgets-2.15.0.dist-info → bec_widgets-2.16.0.dist-info}/RECORD +21 -19
- pyproject.toml +1 -1
- {bec_widgets-2.15.0.dist-info → bec_widgets-2.16.0.dist-info}/WHEEL +0 -0
- {bec_widgets-2.15.0.dist-info → bec_widgets-2.16.0.dist-info}/entry_points.txt +0 -0
- {bec_widgets-2.15.0.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()
|
@@ -25,17 +25,29 @@ class ScrollLabel(QLabel):
|
|
25
25
|
self._step_px = step_px
|
26
26
|
|
27
27
|
def setText(self, text):
|
28
|
+
"""
|
29
|
+
Overridden to ensure that new text replaces the current one
|
30
|
+
immediately.
|
31
|
+
If the label was already scrolling (or in its delay phase),
|
32
|
+
the next message starts **without** the extra delay.
|
33
|
+
"""
|
34
|
+
# Determine whether the widget was already in a scrolling cycle
|
35
|
+
was_scrolling = self._timer.isActive() or self._delay_timer.isActive()
|
36
|
+
|
28
37
|
super().setText(text)
|
38
|
+
|
29
39
|
fm = QFontMetrics(self.font())
|
30
40
|
self._text_width = fm.horizontalAdvance(text)
|
31
41
|
self._offset = 0
|
32
|
-
|
42
|
+
|
43
|
+
# Skip the delay when we were already scrolling
|
44
|
+
self._update_timer(skip_delay=was_scrolling)
|
33
45
|
|
34
46
|
def resizeEvent(self, event):
|
35
47
|
super().resizeEvent(event)
|
36
48
|
self._update_timer()
|
37
49
|
|
38
|
-
def _update_timer(self):
|
50
|
+
def _update_timer(self, *, skip_delay: bool = False):
|
39
51
|
"""
|
40
52
|
Decide whether to start or stop scrolling.
|
41
53
|
|
@@ -46,10 +58,19 @@ class ScrollLabel(QLabel):
|
|
46
58
|
needs_scroll = self._text_width > self.width()
|
47
59
|
|
48
60
|
if needs_scroll:
|
61
|
+
# Reset any running timers
|
49
62
|
if self._timer.isActive():
|
50
63
|
self._timer.stop()
|
64
|
+
if self._delay_timer.isActive():
|
65
|
+
self._delay_timer.stop()
|
66
|
+
|
51
67
|
self._offset = 0
|
52
|
-
|
68
|
+
|
69
|
+
# Start scrolling immediately when we should skip the delay,
|
70
|
+
# otherwise apply the configured delay_ms interval
|
71
|
+
if skip_delay:
|
72
|
+
self._timer.start()
|
73
|
+
else:
|
53
74
|
self._delay_timer.start()
|
54
75
|
else:
|
55
76
|
if self._delay_timer.isActive():
|
@@ -1,7 +1,7 @@
|
|
1
1
|
import os
|
2
2
|
|
3
3
|
from bec_lib.endpoints import MessageEndpoints
|
4
|
-
from qtpy.QtCore import QEvent, QSize, Qt
|
4
|
+
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
|
5
5
|
from qtpy.QtGui import QAction, QActionGroup, QIcon
|
6
6
|
from qtpy.QtWidgets import QApplication, QFrame, QLabel, QMainWindow, QStyle, QVBoxLayout, QWidget
|
7
7
|
|
@@ -80,6 +80,11 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|
80
80
|
)
|
81
81
|
status_bar.addWidget(self._client_info_label, 1)
|
82
82
|
|
83
|
+
# Timer to automatically clear client messages once they expire
|
84
|
+
self._client_info_expire_timer = QTimer(self)
|
85
|
+
self._client_info_expire_timer.setSingleShot(True)
|
86
|
+
self._client_info_expire_timer.timeout.connect(lambda: self._client_info_label.setText(""))
|
87
|
+
|
83
88
|
def _add_separator(self):
|
84
89
|
"""
|
85
90
|
Add a vertically centred separator to the status bar.
|
@@ -222,9 +227,24 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|
222
227
|
|
223
228
|
@SafeSlot(dict, dict)
|
224
229
|
def display_client_message(self, msg: dict, meta: dict):
|
230
|
+
"""
|
231
|
+
Display a client message in the status bar.
|
232
|
+
|
233
|
+
Args:
|
234
|
+
msg(dict): The message to display, should contain:
|
235
|
+
meta(dict): Metadata about the message, usually empty.
|
236
|
+
"""
|
237
|
+
# self._client_info_label.setText("")
|
225
238
|
message = msg.get("message", "")
|
239
|
+
expiration = msg.get("expire", 0) # 0 → never expire
|
226
240
|
self._client_info_label.setText(message)
|
227
241
|
|
242
|
+
# Restart the expiration timer if necessary
|
243
|
+
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
|
244
|
+
self._client_info_expire_timer.stop()
|
245
|
+
if expiration and expiration > 0:
|
246
|
+
self._client_info_expire_timer.start(int(expiration * 1000))
|
247
|
+
|
228
248
|
################################################################################
|
229
249
|
# General and Cleanup Methods
|
230
250
|
################################################################################
|
@@ -259,6 +279,8 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|
259
279
|
child.close()
|
260
280
|
child.deleteLater()
|
261
281
|
|
282
|
+
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
|
283
|
+
self._client_info_expire_timer.stop()
|
262
284
|
# Status bar widgets cleanup
|
263
285
|
self._client_info_label.cleanup()
|
264
286
|
super().cleanup()
|
@@ -266,3 +288,12 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|
266
288
|
|
267
289
|
class UILaunchWindow(BECMainWindow):
|
268
290
|
RPC = True
|
291
|
+
|
292
|
+
|
293
|
+
if __name__ == "__main__":
|
294
|
+
import sys
|
295
|
+
|
296
|
+
app = QApplication(sys.argv)
|
297
|
+
main_window = UILaunchWindow()
|
298
|
+
main_window.show()
|
299
|
+
sys.exit(app.exec())
|