1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-14 12:40:54 +02:00

Compare commits

...

9 Commits

Author SHA1 Message Date
semantic-release
f12339e6f9 2.45.7
Automatically generated by python-semantic-release
2025-12-08 14:04:02 +00:00
ce8e5f0bec fix: handle none in literal combobox 2025-12-08 15:03:17 +01:00
semantic-release
7ea9ab5175 2.45.6
Automatically generated by python-semantic-release
2025-11-27 09:43:46 +00:00
b72f0dc6e8 fix(curve): update dap curves if data are set manually 2025-11-27 10:42:58 +01:00
semantic-release
cb9d429884 2.45.5
Automatically generated by python-semantic-release
2025-11-26 13:25:28 +00:00
0a80bd0a92 fix: remove ghost widgets in scan metadata 2025-11-26 14:24:36 +01:00
semantic-release
9bc9d355e2 2.45.4
Automatically generated by python-semantic-release
2025-11-24 13:30:46 +00:00
7d5e702a11 fix(web_links): fixed link to bec widget issues from gitlab to github 2025-11-24 14:29:56 +01:00
40cbf7fe4f fix(main_window): removed hiding scan progressbar animation 2025-11-24 14:29:56 +01:00
11 changed files with 114 additions and 129 deletions

View File

@@ -1,6 +1,41 @@
# CHANGELOG
## v2.45.7 (2025-12-08)
### Bug Fixes
- Handle none in literal combobox
([`ce8e5f0`](https://github.com/bec-project/bec_widgets/commit/ce8e5f0bec7643c9f826e06f987775de95abb91d))
## v2.45.6 (2025-11-27)
### Bug Fixes
- **curve**: Update dap curves if data are set manually
([`b72f0dc`](https://github.com/bec-project/bec_widgets/commit/b72f0dc6e8474a65c83f7e2c938fc6356b7b5f3a))
## v2.45.5 (2025-11-26)
### Bug Fixes
- Remove ghost widgets in scan metadata
([`0a80bd0`](https://github.com/bec-project/bec_widgets/commit/0a80bd0a9279cef1136a04c252c97e624ef2e779))
## v2.45.4 (2025-11-24)
### Bug Fixes
- **main_window**: Removed hiding scan progressbar animation
([`40cbf7f`](https://github.com/bec-project/bec_widgets/commit/40cbf7fe4f834a1a65306e54b3882d2c0495f90a))
- **web_links**: Fixed link to bec widget issues from gitlab to github
([`7d5e702`](https://github.com/bec-project/bec_widgets/commit/7d5e702a11043ed96a8cb97fce6b2162681e8fab))
## v2.45.3 (2025-11-17)
### Bug Fixes

View File

@@ -81,10 +81,11 @@ class TypedForm(BECWidget, QWidget):
self._form_grid_container = QWidget(parent=self)
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid_container.setLayout(QVBoxLayout())
self._layout.addWidget(self._form_grid_container)
self._form_grid = QWidget(parent=self._form_grid_container)
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout())
self._form_grid.setLayout(self._new_grid_layout())
self._widget_types: dict | None = None
@@ -105,11 +106,11 @@ class TypedForm(BECWidget, QWidget):
def _add_griditem(self, item: FormItemSpec, row: int):
grid = self._form_grid.layout()
label = QLabel(item.name)
label = QLabel(parent=self._form_grid, text=item.name)
label.setProperty("_model_field_name", item.name)
label.setToolTip(item.info.description or item.name)
grid.addWidget(label, row, 0)
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
widget = self._widget_from_type(item, self._widget_types)(parent=self._form_grid, spec=item)
widget.valueChanged.connect(self.value_changed)
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
grid.addWidget(widget, row, 1)
@@ -128,19 +129,17 @@ class TypedForm(BECWidget, QWidget):
}
def _clear_grid(self):
if (old_layout := self._form_grid.layout()) is not None:
while old_layout.count():
item = old_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
old_layout.deleteLater()
self._form_grid.deleteLater()
gl = self._form_grid.layout()
while w := gl.takeAt(0):
w = w.widget()
if hasattr(w, "teardown"):
w.teardown()
w.deleteLater()
self._form_grid_container.layout().removeWidget(self._form_grid)
self._form_grid.deleteLater()
self._form_grid = QWidget()
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid)
self.update_size()
def update_size(self):
@@ -149,7 +148,7 @@ class TypedForm(BECWidget, QWidget):
self.adjustSize()
def _new_grid_layout(self):
new_grid = QGridLayout()
new_grid = QGridLayout(self)
new_grid.setContentsMargins(0, 0, 0, 0)
return new_grid

View File

@@ -3,14 +3,16 @@ from __future__ import annotations
import typing
from abc import abstractmethod
from decimal import Decimal
from types import GenericAlias, UnionType
from types import GenericAlias, NoneType, UnionType
from typing import (
Any,
Callable,
Final,
Generic,
Iterable,
Literal,
NamedTuple,
Optional,
OrderedDict,
TypeVar,
get_args,
@@ -71,7 +73,7 @@ class FormItemSpec(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType | GenericAlias
item_type: type | UnionType | GenericAlias | Optional[Any]
name: str
info: FieldInfo = FieldInfo()
pretty_display: bool = Field(
@@ -188,6 +190,10 @@ class DynamicFormItem(QWidget):
"""Add the main data entry widget to self._main_widget and appply any
constraints from the field info"""
@SafeSlot()
def clear(self, *_):
return
def _set_pretty_display(self):
self.setEnabled(False)
if button := getattr(self, "_clear_button", None):
@@ -204,11 +210,17 @@ class DynamicFormItem(QWidget):
self._layout.addWidget(self._clear_button)
# the widget added in _add_main_widget must implement .clear() if value is not required
self._clear_button.setToolTip("Clear value or reset to default.")
self._clear_button.clicked.connect(self._main_widget.clear) # type: ignore
self._clear_button.clicked.connect(self.clear) # type: ignore
def _value_changed(self, *_, **__):
self.valueChanged.emit()
def teardown(self):
self._layout.deleteLater()
self._layout.removeWidget(self._main_widget)
self._main_widget.deleteLater()
self._main_widget = None
class StrFormItem(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
@@ -542,11 +554,14 @@ class StrLiteralFormItem(DynamicFormItem):
self._layout.addWidget(self._main_widget)
def getValue(self):
if self._main_widget.currentIndex() == -1:
return None
return self._main_widget.currentText()
def setValue(self, value: str | None):
if value is None:
self.clear()
return
for i in range(self._main_widget.count()):
if self._main_widget.itemText(i) == value:
self._main_widget.setCurrentIndex(i)
@@ -557,15 +572,39 @@ class StrLiteralFormItem(DynamicFormItem):
self._main_widget.setCurrentIndex(-1)
class OptionalStrLiteralFormItem(StrLiteralFormItem):
def _add_main_widget(self) -> None:
self._main_widget = QComboBox()
self._options = get_args(get_args(self._spec.info.annotation)[0])
for opt in self._options:
self._main_widget.addItem(opt)
self._layout.addWidget(self._main_widget)
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
def _is_string_literal(t: type):
return type(t) is type(Literal[""]) and set(type(arg) for arg in get_args(t)) == {str}
def _is_optional_string_literal(t: type):
if not hasattr(t, "__args__"):
return False
if len(t.__args__) != 2:
return False
if _is_string_literal(t.__args__[0]) and t.__args__[1] is NoneType:
return True
return False
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
# and delete/insert keys or change the order
"literal_str": (
lambda spec: type(spec.info.annotation) is type(Literal[""])
and set(type(arg) for arg in get_args(spec.info.annotation)) == {str},
StrLiteralFormItem,
"literal_str": (lambda spec: _is_string_literal(spec.info.annotation), StrLiteralFormItem),
"optional_literal_str": (
lambda spec: _is_optional_string_literal(spec.info.annotation),
OptionalStrLiteralFormItem,
),
"str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem),
"int": (lambda spec: spec.item_type in [int, int | None], IntFormItem),
@@ -616,6 +655,8 @@ if __name__ == "__main__": # pragma: no cover
value5: int | None = Field()
value6: list[int] = Field()
value7: list = Field()
literal: Literal["a", "b", "c"]
nullable_literal: Literal["a", "b", "c"] | None = None
app = QApplication([])
w = QWidget()
@@ -623,7 +664,7 @@ if __name__ == "__main__": # pragma: no cover
w.setLayout(layout)
items = []
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
layout.addWidget(QLabel(field_name), i, 0)
widg = widget_from_type(spec)(spec=spec)
items.append(widg)

View File

@@ -12,4 +12,4 @@ class BECWebLinksMixin:
@staticmethod
def open_bec_bug_report():
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")
webbrowser.open("https://github.com/bec-project/bec_widgets/issues")

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import os
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import (
QApplication,
@@ -42,7 +42,7 @@ class BECMainWindow(BECWidget, QMainWindow):
RPC = True
PLUGIN = True
SCAN_PROGRESS_WIDTH = 100 # px
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
SCAN_PROGRESS_HEIGHT = 12 # px
def __init__(
self,
@@ -201,8 +201,8 @@ class BECMainWindow(BECWidget, QMainWindow):
self._scan_progress_bar_simple.show_remaining_time = False
self._scan_progress_bar_simple.show_source_label = False
self._scan_progress_bar_simple.progressbar.label_template = ""
self._scan_progress_bar_simple.progressbar.setFixedHeight(8)
self._scan_progress_bar_simple.progressbar.setFixedWidth(80)
self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
self._scan_progress_bar_full = ScanProgressBar(self)
self._scan_progress_hover = HoverWidget(
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
@@ -219,62 +219,8 @@ class BECMainWindow(BECWidget, QMainWindow):
self._scan_progress_bar_with_separator.layout.addWidget(separator)
self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover)
# Set Size
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH
self._scan_progress_bar_with_separator.setMaximumWidth(self._scan_progress_bar_target_width)
self.status_bar.addWidget(self._scan_progress_bar_with_separator)
# Visibility logic
self._scan_progress_bar_with_separator.hide()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
# Timer for hiding logic
self._scan_progress_hide_timer = QTimer(self)
self._scan_progress_hide_timer.setSingleShot(True)
self._scan_progress_hide_timer.setInterval(self.STATUS_BAR_WIDGETS_EXPIRE_TIME)
self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
# Show / hide behaviour
self._scan_progress_bar_simple.progress_started.connect(self._show_scan_progress_bar)
self._scan_progress_bar_simple.progress_finished.connect(self._delay_hide_scan_progress_bar)
def _show_scan_progress_bar(self):
if self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
if self._scan_progress_bar_with_separator.isVisible():
return
# Make visible and reset width
self._scan_progress_bar_with_separator.show()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
self._show_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._show_container_anim.setDuration(300)
self._show_container_anim.setStartValue(0)
self._show_container_anim.setEndValue(self._scan_progress_bar_target_width)
self._show_container_anim.setEasingCurve(QEasingCurve.OutCubic)
self._show_container_anim.start()
def _delay_hide_scan_progress_bar(self):
"""Start the countdown to hide the scan progress bar."""
if hasattr(self, "_scan_progress_hide_timer"):
self._scan_progress_hide_timer.start()
def _animate_hide_scan_progress_bar(self):
"""Shrink container to the right, then hide."""
self._hide_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._hide_container_anim.setDuration(300)
self._hide_container_anim.setStartValue(self._scan_progress_bar_with_separator.width())
self._hide_container_anim.setEndValue(0)
self._hide_container_anim.setEasingCurve(QEasingCurve.InCubic)
self._hide_container_anim.finished.connect(self._scan_progress_bar_with_separator.hide)
self._hide_container_anim.start()
def _add_separator(self, separate_object: bool = False) -> QWidget | None:
"""
Add a vertically centred separator to the status bar or just return it as a separate object.
@@ -474,8 +420,6 @@ class BECMainWindow(BECWidget, QMainWindow):
# Timer cleanup
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
self._client_info_expire_timer.stop()
if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
########################################
# Status bar widgets cleanup

View File

@@ -85,7 +85,6 @@ class ScanMetadata(PydanticModelForm):
def set_schema_from_scan(self, scan_name: str | None):
self._scan_name = scan_name or ""
self.set_schema(get_metadata_schema_for_scan(self._scan_name))
self.populate()
if __name__ == "__main__": # pragma: no cover

View File

@@ -206,6 +206,7 @@ class Curve(BECConnector, pg.PlotDataItem):
"""
if self.config.source in ["custom", "history"]:
self.setData(x, y)
self.parent_item.request_dap_update.emit()
else:
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")

View File

@@ -5,7 +5,7 @@ from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS
from bec_lib.config_helper import ConfigHelper
from bec_lib.logger import bec_logger
from pydantic import ValidationError, field_validator
from pydantic import field_validator
from qtpy.QtCore import QSize, Qt, QThreadPool, Signal
from qtpy.QtWidgets import (
QApplication,

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.45.3"
version = "2.45.7"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [

View File

@@ -191,51 +191,10 @@ def test_bec_weblinks(monkeypatch):
assert opened_urls == [
"https://beamline-experiment-control.readthedocs.io/en/latest/",
"https://bec.readthedocs.io/projects/bec-widgets/en/latest/",
"https://gitlab.psi.ch/groups/bec/-/issues/",
"https://github.com/bec-project/bec_widgets/issues",
]
#################################################################
# Tests for scanprogress bar animations
def test_scan_progress_bar_show_animation(qtbot, bec_main_window):
"""
_show_scan_progress_bar should animate the container's maximumWidth
from 0 to the configured target width.
"""
container = bec_main_window._scan_progress_bar_with_separator
# Precondition: collapsed
assert container.maximumWidth() == 0
bec_main_window._show_scan_progress_bar()
target = bec_main_window._scan_progress_bar_target_width
qtbot.waitUntil(lambda: container.maximumWidth() == target, timeout=2000)
assert container.maximumWidth() == target
def test_scan_progress_bar_hide_animation(qtbot, bec_main_window):
"""
_animate_hide_scan_progress_bar should collapse the container back to 0 width.
"""
container = bec_main_window._scan_progress_bar_with_separator
# First expand it
bec_main_window._show_scan_progress_bar()
target = bec_main_window._scan_progress_bar_target_width
qtbot.waitUntil(lambda: container.maximumWidth() == target, timeout=2000)
# Trigger hide animation
bec_main_window._animate_hide_scan_progress_bar()
qtbot.waitUntil(lambda: container.maximumWidth() == 0, timeout=2000)
assert container.maximumWidth() == 0
#################################################################
# Tests for hover widget and tooltip behaviour

View File

@@ -496,6 +496,13 @@ def test_add_dap_curve_custom_source(qtbot, mocked_client_with_dap):
assert dap_curve.config.signal.dap == "GaussianModel"
def test_curve_set_data_emits_dap_update(qtbot, mocked_client):
wf = create_widget(qtbot, Waveform, client=mocked_client)
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="test_curve")
with qtbot.waitSignal(wf.request_dap_update):
c.set_data([7, 8, 9], [10, 11, 12])
def test_plot_custom_curve_with_inline_dap(qtbot, mocked_client_with_dap):
"""
Supplying the `dap` kwarg when plotting custom data should auto-create the fit curve.