1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 09:47:52 +02:00

Compare commits

..

32 Commits

Author SHA1 Message Date
semantic-release
ae26b43fb1 2.19.1
Automatically generated by python-semantic-release
2025-06-23 14:07:09 +00:00
7484f5160c fix(launch_window): number of remaining connections extended to 4 2025-06-23 16:06:27 +02:00
6421050116 feat(hover_widget) widget enables to display different widget upon hover; applied to scan progress and client info message in status bar of BECMainWindow 2025-06-23 16:06:27 +02:00
semantic-release
5a137d1219 2.19.0
Automatically generated by python-semantic-release
2025-06-23 12:54:48 +00:00
d5a40dabc7 fix(ci): extend check for pyside import to tests 2025-06-23 14:54:06 +02:00
f3da6e959e feat: (#494) add signal display to device browser 2025-06-23 14:54:06 +02:00
3a103410e7 feat: (#494) display device signals 2025-06-23 14:54:06 +02:00
3378051250 feat: (#494) add tabbed layout for device item 2025-06-23 14:54:06 +02:00
semantic-release
77db658f3d 2.18.0
Automatically generated by python-semantic-release
2025-06-22 17:40:06 +00:00
6e2f2cea91 refactor(device input): refactor to SafeProperty and SafeSlot 2025-06-22 19:39:19 +02:00
eea5f7ebbd feat(curve settings): add combobox selection for device and signal 2025-06-22 19:39:19 +02:00
a9708f6d8f fix(curve settings): add initial size hint 2025-06-22 19:39:19 +02:00
b51de1a00e feat(signal combobox): add reset_selection slot 2025-06-22 19:39:19 +02:00
8e8acd672c feat(FilterIO): add support for item data 2025-06-22 19:39:19 +02:00
4c2c0c5525 feat(device combobox): emit reset event if validation fails 2025-06-22 19:39:19 +02:00
5a564a5f3f fix: make settings dialog resizable 2025-06-22 19:39:19 +02:00
semantic-release
43ad207aa8 2.17.0
Automatically generated by python-semantic-release
2025-06-22 13:33:32 +00:00
a4274ff8cd build: update min dependency of bec to 3.42.4 2025-06-22 15:32:45 +02:00
b2a46e284d test(scan progress): add test for queue update logic 2025-06-22 15:32:45 +02:00
9ff170660e feat(main_window): timer to show hide scan progress when it is relevant only 2025-06-22 15:32:45 +02:00
6c04eac18c test(scan_progress): tests extended 2025-06-22 15:32:45 +02:00
aca6efb567 fix(main_window): labels and sizing of scan progress adopted 2025-06-22 15:32:45 +02:00
88b42e49e3 fix(scan_progressbar): mapping of bec progress states to the progressbar enums 2025-06-22 15:32:45 +02:00
d3a9e0903a feat(progressbar): state setting and dynamic corner radius 2025-06-22 15:32:45 +02:00
3bbb8daa24 fix(launch_window): number of remaining connections increase to 2 to include the ScanProgressBar 2025-06-22 15:32:45 +02:00
e8ae9725fa fix(scan_progressbar): cleanup adjusted 2025-06-22 15:32:45 +02:00
497e394deb feat(main_window): added scan progress bar to BECMainWindow status bar 2025-06-22 15:32:45 +02:00
d5ca7b8433 feat(scan_progressbar): added oneline design for compact applications 2025-06-22 15:32:45 +02:00
b02c870dbf fix(bec_progressbar): layout and sizing adjustments 2025-06-22 15:32:45 +02:00
92d0ffee65 refactor(progressbar): change slot / property to safeslot / safeproperty 2025-06-22 15:32:45 +02:00
c4b85381a4 feat(scan_progressbar): added progressbar with hooks to scan progress and device progress 2025-06-22 15:32:45 +02:00
a451625a5a feat(progressbar): added padding as designer property 2025-06-22 15:32:45 +02:00
36 changed files with 2175 additions and 157 deletions

View File

@@ -21,7 +21,7 @@ jobs:
isort --check --diff ./
- name: Check for disallowed imports from PySide
run: '! grep -re "from PySide6\." bec_widgets/ | grep -v -e "PySide6.QtDesigner" -e "PySide6.scripts"'
run: '! grep -re "from PySide6\." bec_widgets/ tests/ | grep -v -e "PySide6.QtDesigner" -e "PySide6.scripts"'
Pylint:
runs-on: ubuntu-latest

View File

@@ -1,6 +1,121 @@
# CHANGELOG
## v2.19.1 (2025-06-23)
### Bug Fixes
- **launch_window**: Number of remaining connections extended to 4
([`7484f51`](https://github.com/bec-project/bec_widgets/commit/7484f5160c8c6d632fd27996035ff6c0dda2e657))
## v2.19.0 (2025-06-23)
### Bug Fixes
- **ci**: Extend check for pyside import to tests
([`d5a40da`](https://github.com/bec-project/bec_widgets/commit/d5a40dabc74753acad05e3eb6b121499fc1e03d7))
### Features
- (#494) add signal display to device browser
([`f3da6e9`](https://github.com/bec-project/bec_widgets/commit/f3da6e959e0416827ee5d02e34e6ad0ecfc8e5e7))
- (#494) add tabbed layout for device item
([`3378051`](https://github.com/bec-project/bec_widgets/commit/337805125098c3e028a17b74ef6d9ae4b9ba3d6d))
- (#494) display device signals
([`3a10341`](https://github.com/bec-project/bec_widgets/commit/3a103410e7448256a56b59bb3276fee056ec42a0))
## v2.18.0 (2025-06-22)
### Bug Fixes
- Make settings dialog resizable
([`5a564a5`](https://github.com/bec-project/bec_widgets/commit/5a564a5f3f3229e6407ea52a59d3e63319dc214a))
- **curve settings**: Add initial size hint
([`a9708f6`](https://github.com/bec-project/bec_widgets/commit/a9708f6d8f15c42b142488da1e392a8f3179932a))
### Features
- **curve settings**: Add combobox selection for device and signal
([`eea5f7e`](https://github.com/bec-project/bec_widgets/commit/eea5f7ebbd2b477b3ed19c7efcc76390dd391f26))
- **device combobox**: Emit reset event if validation fails
([`4c2c0c5`](https://github.com/bec-project/bec_widgets/commit/4c2c0c5525d593d8ec7fd554336cb11adbe32de2))
- **FilterIO**: Add support for item data
([`8e8acd6`](https://github.com/bec-project/bec_widgets/commit/8e8acd672c0deb8dcd928886fb574452ac956de7))
- **signal combobox**: Add reset_selection slot
([`b51de1a`](https://github.com/bec-project/bec_widgets/commit/b51de1a00e4b17c44cab23e5097391c6fa8ea0e2))
### Refactoring
- **device input**: Refactor to SafeProperty and SafeSlot
([`6e2f2ce`](https://github.com/bec-project/bec_widgets/commit/6e2f2cea91ba3af33e9891532506f4b0b65b90c8))
## v2.17.0 (2025-06-22)
### Bug Fixes
- **bec_progressbar**: Layout and sizing adjustments
([`b02c870`](https://github.com/bec-project/bec_widgets/commit/b02c870dbfecb4bc6921ec4c915dac0e67beb9b4))
- **launch_window**: Number of remaining connections increase to 2 to include the ScanProgressBar
([`3bbb8da`](https://github.com/bec-project/bec_widgets/commit/3bbb8daa24348613f62bde667a446d37dcec8fb0))
- **main_window**: Labels and sizing of scan progress adopted
([`aca6efb`](https://github.com/bec-project/bec_widgets/commit/aca6efb567528eb3c68521a59b4f9903a5616c6f))
- **scan_progressbar**: Cleanup adjusted
([`e8ae972`](https://github.com/bec-project/bec_widgets/commit/e8ae9725fa86b7db52a147ca5a2acc62fa2ccf43))
- **scan_progressbar**: Mapping of bec progress states to the progressbar enums
([`88b42e4`](https://github.com/bec-project/bec_widgets/commit/88b42e49e30a0aa0edc2de4d970408f4be5bde6b))
### Build System
- Update min dependency of bec to 3.42.4
([`a4274ff`](https://github.com/bec-project/bec_widgets/commit/a4274ff8cd9f3e73a61b2eaf902c172c028d21b0))
### Features
- **main_window**: Added scan progress bar to BECMainWindow status bar
([`497e394`](https://github.com/bec-project/bec_widgets/commit/497e394deb5cfe36c8fc4f769fef26f109fd1c1f))
- **main_window**: Timer to show hide scan progress when it is relevant only
([`9ff1706`](https://github.com/bec-project/bec_widgets/commit/9ff170660edd9e03f99eccee60b5e20fc1cf5a8d))
- **progressbar**: Added padding as designer property
([`a451625`](https://github.com/bec-project/bec_widgets/commit/a451625a5ab804ca8259f9c9f83c4f9ebbea4a5b))
- **progressbar**: State setting and dynamic corner radius
([`d3a9e09`](https://github.com/bec-project/bec_widgets/commit/d3a9e0903a263d735ecab3a2ad9319c9d5e86092))
- **scan_progressbar**: Added oneline design for compact applications
([`d5ca7b8`](https://github.com/bec-project/bec_widgets/commit/d5ca7b84337cf60aa66f961d357ae66994f53c7a))
- **scan_progressbar**: Added progressbar with hooks to scan progress and device progress
([`c4b8538`](https://github.com/bec-project/bec_widgets/commit/c4b85381a41e4742567680864668ee83d498b1d1))
### Refactoring
- **progressbar**: Change slot / property to safeslot / safeproperty
([`92d0ffe`](https://github.com/bec-project/bec_widgets/commit/92d0ffee65babc718fafd60131d0a4f291e5ca2b))
### Testing
- **scan progress**: Add test for queue update logic
([`b2a46e2`](https://github.com/bec-project/bec_widgets/commit/b2a46e284d45e97dd9853d1a3c8e95de7e530267))
- **scan_progress**: Tests extended
([`6c04eac`](https://github.com/bec-project/bec_widgets/commit/6c04eac18c887526b333f58fc1118c3b4029abd8))
## v2.16.2 (2025-06-20)
### Bug Fixes

View File

@@ -542,7 +542,7 @@ class LaunchWindow(BECMainWindow):
remaining_connections = [
connection for connection in connections.values() if connection.parent_id != self.gui_id
]
return len(remaining_connections) <= 1
return len(remaining_connections) <= 4
def _turn_off_the_lights(self, connections: dict):
"""

View File

@@ -474,6 +474,20 @@ class BECProgressBar(RPCBase):
>>> progressbar.label_template = "$value / $percentage %"
"""
@property
@rpc_call
def state(self):
"""
None
"""
@state.setter
@rpc_call
def state(self):
"""
None
"""
@rpc_call
def _get_label(self) -> str:
"""
@@ -3245,6 +3259,16 @@ class ScanControl(RPCBase):
"""
class ScanProgressBar(RPCBase):
"""Widget to display a progress bar that is hooked up to the scan progress of a scan."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class ScatterCurve(RPCBase):
"""Scatter curve item for the scatter waveform widget."""

View File

@@ -96,9 +96,21 @@ class FakePositioner(BECPositioner):
}
self._info = {
"signals": {
"readback": {"kind_str": "hinted"}, # hinted
"setpoint": {"kind_str": "normal"}, # normal
"velocity": {"kind_str": "config"}, # config
"readback": {
"kind_str": "hinted",
"component_name": "readback",
"obj_name": self.name,
}, # hinted
"setpoint": {
"kind_str": "normal",
"component_name": "setpoint",
"obj_name": f"{self.name}_setpoint",
}, # normal
"velocity": {
"kind_str": "config",
"component_name": "velocity",
"obj_name": f"{self.name}_velocity",
}, # config
}
}
self.signals = {

View File

@@ -8,6 +8,8 @@ from bec_lib.logger import bec_logger
from qtpy.QtCore import QStringListModel
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
from bec_widgets.utils.ophyd_kind_util import Kind
logger = bec_logger.logger
@@ -15,11 +17,13 @@ class WidgetFilterHandler(ABC):
"""Abstract base class for widget filter handlers"""
@abstractmethod
def set_selection(self, widget, selection: list) -> None:
def set_selection(self, widget, selection: list[str | tuple]) -> None:
"""Set the filtered_selection for the widget
Args:
selection (list): Filtered selection of items
widget: Widget instance
selection (list[str | tuple]): Filtered selection of items.
If tuple, it contains (text, data) pairs.
"""
@abstractmethod
@@ -34,17 +38,37 @@ class WidgetFilterHandler(ABC):
bool: True if the input text is in the filtered selection
"""
@abstractmethod
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
# This method should be implemented in subclasses or extended as needed
class LineEditFilterHandler(WidgetFilterHandler):
"""Handler for QLineEdit widget"""
def set_selection(self, widget: QLineEdit, selection: list) -> None:
def set_selection(self, widget: QLineEdit, selection: list[str | tuple]) -> None:
"""Set the selection for the widget to the completer model
Args:
widget (QLineEdit): The QLineEdit widget
selection (list): Filtered selection of items
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
"""
if isinstance(selection, tuple):
# If selection is a tuple, it contains (text, data) pairs
selection = [text for text, _ in selection]
if not isinstance(widget.completer, QCompleter):
completer = QCompleter(widget)
widget.setCompleter(completer)
@@ -64,19 +88,47 @@ class LineEditFilterHandler(WidgetFilterHandler):
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
return text in model_data
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
return [
signal
for signal, signal_info in device_info.items()
if kind in signal_filter and (signal_info.get("kind_str", None) == str(kind.name))
]
class ComboBoxFilterHandler(WidgetFilterHandler):
"""Handler for QComboBox widget"""
def set_selection(self, widget: QComboBox, selection: list) -> None:
def set_selection(self, widget: QComboBox, selection: list[str | tuple]) -> None:
"""Set the selection for the widget to the completer model
Args:
widget (QComboBox): The QComboBox widget
selection (list): Filtered selection of items
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
"""
widget.clear()
widget.addItems(selection)
if len(selection) == 0:
return
for element in selection:
if isinstance(element, str):
widget.addItem(element)
elif isinstance(element, tuple):
# If element is a tuple, it contains (text, data) pairs
widget.addItem(*element)
def check_input(self, widget: QComboBox, text: str) -> bool:
"""Check if the input text is in the filtered selection
@@ -90,6 +142,40 @@ class ComboBoxFilterHandler(WidgetFilterHandler):
"""
return text in [widget.itemText(i) for i in range(widget.count())]
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
out = []
for signal, signal_info in device_info.items():
if kind not in signal_filter or (signal_info.get("kind_str", None) != str(kind.name)):
continue
obj_name = signal_info.get("obj_name", "")
component_name = signal_info.get("component_name", "")
signal_wo_device = obj_name.removeprefix(f"{device_name}_")
if not signal_wo_device:
signal_wo_device = obj_name
if signal_wo_device != signal and component_name.replace(".", "_") != signal_wo_device:
# If the object name is not the same as the signal name, we use the object name
# to display in the combobox.
out.append((f"{signal_wo_device} ({signal})", signal_info))
else:
# If the object name is the same as the signal name, we do not change it.
out.append((signal, signal_info))
return out
class FilterIO:
"""Public interface to set filters for input widgets.
@@ -99,13 +185,14 @@ class FilterIO:
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
@staticmethod
def set_selection(widget, selection: list, ignore_errors=True):
def set_selection(widget, selection: list[str | tuple], ignore_errors=True):
"""
Retrieve value from the widget instance.
Args:
widget: Widget instance.
selection(list): List of filtered selection items.
selection (list[str | tuple]): Filtered selection of items.
If tuple, it contains (text, data) pairs.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = FilterIO._find_handler(widget)
@@ -139,6 +226,35 @@ class FilterIO:
)
return None
@staticmethod
def update_with_kind(
widget, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""
Update the selection based on the kind of signal.
Args:
widget: Widget instance.
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().update_with_kind(
kind=kind,
signal_filter=signal_filter,
device_info=device_info,
device_name=device_name,
)
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
@staticmethod
def _find_handler(widget):
"""

View File

@@ -0,0 +1,115 @@
import sys
from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
class WidgetTooltip(QWidget):
"""Frameless, always-on-top window that behaves like a tooltip."""
def __init__(self, content: QWidget) -> None:
super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_ShowWithoutActivating)
self.setMouseTracking(True)
self.content = content
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
layout.addWidget(self.content)
self.adjustSize()
def leaveEvent(self, _event) -> None:
self.hide()
def show_above(self, global_pos: QPoint, offset: int = 8) -> None:
self.adjustSize()
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
screen_geo = screen.availableGeometry()
geom = self.geometry()
x = global_pos.x() - geom.width() // 2
y = global_pos.y() - geom.height() - offset
x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width()))
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
self.move(x, y)
self.show()
class HoverWidget(QWidget):
def __init__(self, parent: QWidget | None = None, *, simple: QWidget, full: QWidget):
super().__init__(parent)
self._simple = simple
self._full = full
self._full.setVisible(False)
self._tooltip = None
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(simple)
def enterEvent(self, event):
# suppress empty-label tooltips for labels
if isinstance(self._full, QLabel) and not self._full.text():
return
if self._tooltip is None: # first time only
self._tooltip = WidgetTooltip(self._full)
self._full.setVisible(True)
centre = self.mapToGlobal(self.rect().center())
self._tooltip.show_above(centre)
super().enterEvent(event)
def leaveEvent(self, event):
if self._tooltip and self._tooltip.isVisible():
self._tooltip.hide()
super().leaveEvent(event)
def close(self):
if self._tooltip:
self._tooltip.close()
self._tooltip.deleteLater()
self._tooltip = None
super().close()
################################################################################
# Demo
# Just a simple example to show how the HoverWidget can be used to display
# a tooltip with a full widget inside (two different widgets are used
# for the simple and full versions).
################################################################################
class DemoSimpleWidget(QLabel): # pragma: no cover
"""A simple widget to be used as a trigger for the tooltip."""
def __init__(self) -> None:
super().__init__()
self.setText("Hover me for a preview!")
class DemoFullWidget(QProgressBar): # pragma: no cover
"""A full widget to be shown in the tooltip."""
def __init__(self) -> None:
super().__init__()
self.setRange(0, 100)
self.setValue(75)
self.setFixedWidth(320)
self.setFixedHeight(30)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = QWidget()
window.layout = QHBoxLayout(window)
hover_widget = HoverWidget(simple=DemoSimpleWidget(), full=DemoFullWidget())
window.layout.addWidget(hover_widget)
window.show()
sys.exit(app.exec_())

View File

@@ -1,9 +1,20 @@
from __future__ import annotations
import os
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import QApplication, QFrame, QLabel, QMainWindow, QStyle, QVBoxLayout, QWidget
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QStyle,
QVBoxLayout,
QWidget,
)
import bec_widgets
from bec_widgets.utils import UILoader
@@ -11,8 +22,10 @@ from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
@@ -20,6 +33,8 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECMainWindow(BECWidget, QMainWindow):
RPC = False
PLUGIN = False
SCAN_PROGRESS_WIDTH = 100 # px
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
def __init__(
self,
@@ -33,6 +48,7 @@ class BECMainWindow(BECWidget, QMainWindow):
super().__init__(parent=parent, gui_id=gui_id, **kwargs)
self.app = QApplication.instance()
self.status_bar = self.statusBar()
self.setWindowTitle(window_title)
self._init_ui()
self._connect_to_theme_change()
@@ -61,33 +77,143 @@ class BECMainWindow(BECWidget, QMainWindow):
"""
Prepare the BEC specific widgets in the status bar.
"""
status_bar = self.statusBar()
# Left: AppID label
self._app_id_label = QLabel()
self._app_id_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
status_bar.addWidget(self._app_id_label)
self.status_bar.addWidget(self._app_id_label)
# Add a separator after the app ID label
self._add_separator()
# Centre: Clientinfo label (stretch=1 so it expands)
self._client_info_label = ScrollLabel()
self._add_client_info_label()
# Add scan_progress bar with display logic
self._add_scan_progress_bar()
################################################################################
# Client message status bar widget helpers
def _add_client_info_label(self):
"""
Add a client info label to the status bar.
This label will display messages from the BEC dispatcher.
"""
# Scroll label for client info in Status Bar
self._client_info_label = ScrollLabel(self)
self._client_info_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
status_bar.addWidget(self._client_info_label, 1)
# Full label used in the hover widget
self._client_info_label_full = QLabel(self)
self._client_info_label_full.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
# Hover widget to show the full client info label
self._client_info_hover = HoverWidget(
self, simple=self._client_info_label, full=self._client_info_label_full
)
self.status_bar.addWidget(self._client_info_hover, 1)
# Timer to automatically clear client messages once they expire
self._client_info_expire_timer = QTimer(self)
self._client_info_expire_timer.setSingleShot(True)
self._client_info_expire_timer.timeout.connect(lambda: self._client_info_label.setText(""))
self._client_info_expire_timer.timeout.connect(
lambda: self._client_info_label_full.setText("")
)
def _add_separator(self):
################################################################################
# Progressbar helpers
def _add_scan_progress_bar(self):
# Setting HoverWidget for the scan progress bar - minimal and full version
self._scan_progress_bar_simple = ScanProgressBar(self, one_line_design=True)
self._scan_progress_bar_simple.show_elapsed_time = False
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_full = ScanProgressBar(self)
self._scan_progress_hover = HoverWidget(
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
)
# Bundle the progress bar with a separator
separator = self._add_separator(separate_object=True)
self._scan_progress_bar_with_separator = QWidget()
self._scan_progress_bar_with_separator.layout = QHBoxLayout(
self._scan_progress_bar_with_separator
)
self._scan_progress_bar_with_separator.layout.setContentsMargins(0, 0, 0, 0)
self._scan_progress_bar_with_separator.layout.setSpacing(0)
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.
Add a vertically centred separator to the status bar or just return it as a separate object.
"""
status_bar = self.statusBar()
@@ -106,6 +232,8 @@ class BECMainWindow(BECWidget, QMainWindow):
vbox.addStretch()
wrapper.setFixedWidth(line.sizeHint().width())
if separate_object:
return wrapper
status_bar.addWidget(wrapper)
def _init_bec_icon(self):
@@ -234,10 +362,10 @@ class BECMainWindow(BECWidget, QMainWindow):
msg(dict): The message to display, should contain:
meta(dict): Metadata about the message, usually empty.
"""
# self._client_info_label.setText("")
message = msg.get("message", "")
expiration = msg.get("expire", 0) # 0 → never expire
self._client_info_label.setText(message)
self._client_info_label_full.setText(message)
# Restart the expiration timer if necessary
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
@@ -279,10 +407,26 @@ class BECMainWindow(BECWidget, QMainWindow):
child.close()
child.deleteLater()
# 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
# Client info label cleanup
self._client_info_label.cleanup()
self._client_info_hover.close()
self._client_info_hover.deleteLater()
# Scan progress bar cleanup
self._scan_progress_bar_simple.close()
self._scan_progress_bar_simple.deleteLater()
self._scan_progress_bar_full.close()
self._scan_progress_bar_full.deleteLater()
self._scan_progress_hover.close()
self._scan_progress_hover.deleteLater()
super().cleanup()
@@ -296,4 +440,5 @@ if __name__ == "__main__":
app = QApplication(sys.argv)
main_window = UILaunchWindow()
main_window.show()
main_window.resize(800, 600)
sys.exit(app.exec())

View File

@@ -6,10 +6,10 @@ from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
from bec_lib.device import Signal as BECSignal
from bec_lib.logger import bec_logger
from pydantic import field_validator
from qtpy.QtCore import Property, Signal, Slot
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.widget_io import WidgetIO
@@ -100,7 +100,7 @@ class DeviceInputBase(BECWidget):
### QtSlots ###
@Slot(str)
@SafeSlot(str)
def set_device(self, device: str):
"""
Set the device.
@@ -114,7 +114,7 @@ class DeviceInputBase(BECWidget):
else:
logger.warning(f"Device {device} is not in the filtered selection.")
@Slot()
@SafeSlot()
def update_devices_from_filters(self):
"""Update the devices based on the current filter selection
in self.device_filter and self.readout_filter. If apply_filter is False,
@@ -133,7 +133,7 @@ class DeviceInputBase(BECWidget):
self.devices = [device.name for device in devs]
self.set_device(current_device)
@Slot(list)
@SafeSlot(list)
def set_available_devices(self, devices: list[str]):
"""
Set the devices. If a device in the list is not valid, it will not be considered.
@@ -146,7 +146,7 @@ class DeviceInputBase(BECWidget):
### QtProperties ###
@Property(
@SafeProperty(
"QStringList",
doc="List of devices. If updated, it will disable the apply filters property.",
)
@@ -165,7 +165,7 @@ class DeviceInputBase(BECWidget):
self.config.devices = value
FilterIO.set_selection(widget=self, selection=value)
@Property(str)
@SafeProperty(str)
def default(self):
"""Get the default device name. If set through this property, it will update only if the device is within the filtered selection."""
return self.config.default
@@ -177,7 +177,7 @@ class DeviceInputBase(BECWidget):
self.config.default = value
WidgetIO.set_value(widget=self, value=value)
@Property(bool)
@SafeProperty(bool)
def apply_filter(self):
"""Apply the filters on the devices."""
return self.config.apply_filter
@@ -187,7 +187,7 @@ class DeviceInputBase(BECWidget):
self.config.apply_filter = value
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def filter_to_device(self):
"""Include devices in filters."""
return BECDeviceFilter.DEVICE in self.device_filter
@@ -200,7 +200,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.DEVICE)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def filter_to_positioner(self):
"""Include devices of type Positioner in filters."""
return BECDeviceFilter.POSITIONER in self.device_filter
@@ -213,7 +213,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.POSITIONER)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def filter_to_signal(self):
"""Include devices of type Signal in filters."""
return BECDeviceFilter.SIGNAL in self.device_filter
@@ -226,7 +226,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.SIGNAL)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def filter_to_computed_signal(self):
"""Include devices of type ComputedSignal in filters."""
return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter
@@ -239,7 +239,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.COMPUTED_SIGNAL)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def readout_monitored(self):
"""Include devices with readout priority Monitored in filters."""
return ReadoutPriority.MONITORED in self.readout_filter
@@ -252,7 +252,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.MONITORED)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def readout_baseline(self):
"""Include devices with readout priority Baseline in filters."""
return ReadoutPriority.BASELINE in self.readout_filter
@@ -265,7 +265,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.BASELINE)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def readout_async(self):
"""Include devices with readout priority Async in filters."""
return ReadoutPriority.ASYNC in self.readout_filter
@@ -278,7 +278,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.ASYNC)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def readout_continuous(self):
"""Include devices with readout priority continuous in filters."""
return ReadoutPriority.CONTINUOUS in self.readout_filter
@@ -291,7 +291,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.CONTINUOUS)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def readout_on_request(self):
"""Include devices with readout priority OnRequest in filters."""
return ReadoutPriority.ON_REQUEST in self.readout_filter

View File

@@ -6,7 +6,7 @@ from qtpy.QtCore import Property
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.filter_io import FilterIO, LineEditFilterHandler
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.utils.widget_io import WidgetIO
@@ -108,25 +108,32 @@ class DeviceSignalInputBase(BECWidget):
if not self.validate_device(self._device):
self._device = None
self.config.device = self._device
return
device = self.get_device_object(self._device)
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [self._device]
self._hinted_signals = [self._device]
self._signals = []
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return
device = self.get_device_object(self._device)
device_info = device._info.get("signals", {})
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [(self._device, {})]
self._hinted_signals = [(self._device, {})]
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return
def _update(kind: Kind):
return [
signal
for signal, signal_info in device_info.items()
if kind in self.signal_filter
and (signal_info.get("kind_str", None) == str(kind.name))
]
return FilterIO.update_with_kind(
widget=self,
kind=kind,
signal_filter=self.signal_filter,
device_info=device_info,
device_name=self._device,
)
self._hinted_signals = _update(Kind.hinted)
self._normal_signals = _update(Kind.normal)
@@ -271,8 +278,11 @@ class DeviceSignalInputBase(BECWidget):
Args:
signal(str): Signal to validate.
"""
if signal in self.signals:
return True
for entry in self.signals:
if isinstance(entry, tuple):
entry = entry[0]
if entry == signal:
return True
return False
def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None):

View File

@@ -34,6 +34,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
PLUGIN = True
device_selected = Signal(str)
device_reset = Signal()
device_config_update = Signal()
def __init__(
@@ -147,6 +148,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self.device_selected.emit(input_text)
else:
self._is_valid_input = False
self.device_reset.emit()
self.update()
def validate_device(self, device: str) -> bool: # type: ignore[override]

View File

@@ -90,6 +90,14 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False)
@SafeSlot()
def reset_selection(self):
"""Reset the selection of the combobox."""
self.clear()
self.setItemText(0, "Select a device")
self.update_signals_from_filters()
self.device_signal_changed.emit("")
@SafeSlot(str)
def on_text_changed(self, text: str):
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
@@ -102,11 +110,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
return
if self.validate_signal(text) is False:
return
if text == "readback" and isinstance(self.get_device_object(self.device), Positioner):
device_signal = self.device
else:
device_signal = f"{self.device}_{text}"
self.device_signal_changed.emit(device_signal)
self.device_signal_changed.emit(text)
if __name__ == "__main__": # pragma: no cover

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from qtpy.QtCore import QSize
from qtpy.QtWidgets import (
QComboBox,
QGroupBox,
@@ -35,7 +36,11 @@ class CurveSetting(SettingWidget):
self._init_x_box()
self._init_y_box()
self.setFixedWidth(580) # TODO height is still debate
def sizeHint(self) -> QSize:
"""
Returns the size hint for the settings widget.
"""
return QSize(800, 500)
def _init_x_box(self):
self.x_axis_box = QGroupBox("X Axis")
@@ -97,7 +102,7 @@ class CurveSetting(SettingWidget):
self.layout.addWidget(self.y_axis_box)
@SafeSlot()
@SafeSlot(popup_error=True)
def accept_changes(self):
"""
Accepts the changes made in the settings widget and applies them to the target widget.

View File

@@ -5,13 +5,12 @@ from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtGui import QColor
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QColorDialog,
QComboBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QSpinBox,
@@ -27,9 +26,8 @@ from bec_widgets.utils import ConnectionConfig, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from bec_widgets.widgets.plots.waveform.curve import CurveConfig, DeviceSignal
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
@@ -125,11 +123,40 @@ class CurveRow(QTreeWidgetItem):
"""Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo."""
if self.source == "device":
# Device row: columns 1..2 are device line edits
self.device_edit = DeviceLineEdit(parent=self.tree)
self.entry_edit = QLineEdit(parent=self.tree) # TODO in future will be signal line edit
self.device_edit = DeviceComboBox(parent=self.tree)
self.device_edit.insertItem(0, "")
self.device_edit.setEditable(True)
self.entry_edit = SignalComboBox(parent=self.tree)
self.entry_edit.include_config_signals = False
self.entry_edit.insertItem(0, "")
self.entry_edit.setEditable(True)
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
if self.config.signal:
self.device_edit.setText(self.config.signal.name or "")
self.entry_edit.setText(self.config.signal.entry or "")
device_index = self.device_edit.findText(self.config.signal.name or "")
if device_index >= 0:
self.device_edit.setCurrentIndex(device_index)
# Force the entry_edit to update based on the device name
self.device_edit.currentTextChanged.emit(self.device_edit.currentText())
else:
# If the device name is not found, set the first enabled item
self.device_edit.setCurrentIndex(0)
for i in range(self.entry_edit.count()):
entry_data = self.entry_edit.itemData(i)
if entry_data and entry_data.get("obj_name") == self.config.signal.entry:
# If the device name matches an object name, set it
self.entry_edit.setCurrentIndex(i)
break
else:
# If no match found, set the first enabled item
for i in range(self.entry_edit.count()):
model = self.entry_edit.model()
if model.flags(model.index(i, 0)) & Qt.ItemIsEnabled:
self.entry_edit.setCurrentIndex(i)
break
else:
self.entry_edit.setCurrentIndex(0)
self.tree.setItemWidget(self, 1, self.device_edit)
self.tree.setItemWidget(self, 2, self.entry_edit)
@@ -268,13 +295,22 @@ class CurveRow(QTreeWidgetItem):
# Gather device name/entry
device_name = ""
device_entry = ""
## TODO: Move this to itemData
if hasattr(self, "device_edit"):
device_name = self.device_edit.text()
device_name = self.device_edit.currentText()
if hasattr(self, "entry_edit"):
device_entry = self.entry_validator.validate_signal(
name=device_name, entry=self.entry_edit.text()
)
self.entry_edit.setText(device_entry)
device_entry = self.entry_edit.currentText()
index = self.entry_edit.findText(device_entry)
if index > -1:
device_entry_info = self.entry_edit.itemData(index)
if device_entry_info:
device_entry = device_entry_info.get("obj_name", device_entry)
else:
device_entry = self.entry_validator.validate_signal(
name=device_name, entry=device_entry
)
self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
self.config.source = "device"
self.config.label = f"{device_name}-{device_entry}"
@@ -390,13 +426,20 @@ class CurveTree(BECWidget, QWidget):
self.tree = QTreeWidget()
self.tree.setColumnCount(7)
self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"])
header = self.tree.header()
for idx in range(self.tree.columnCount()):
if idx in (1, 2): # Device name and entry should stretch
header.setSectionResizeMode(idx, QHeaderView.Stretch)
else:
header.setSectionResizeMode(idx, QHeaderView.Fixed)
header.setStretchLastSection(False)
self.tree.setColumnWidth(0, 90)
self.tree.setColumnWidth(1, 100)
self.tree.setColumnWidth(2, 100)
self.tree.setColumnWidth(3, 70)
self.tree.setColumnWidth(4, 80)
self.tree.setColumnWidth(5, 40)
self.tree.setColumnWidth(6, 40)
self.tree.setColumnWidth(5, 50)
self.tree.setColumnWidth(6, 50)
self.layout.addWidget(self.tree)
def _init_color_buffer(self, size: int):

View File

@@ -330,7 +330,6 @@ class Waveform(PlotBase):
self.curve_settings_dialog = SettingsDialog(
self, settings_widget=curve_setting, window_title="Curve Settings", modal=False
)
self.curve_settings_dialog.setFixedWidth(580)
# When the dialog is closed, update the toolbar icon and clear the reference
self.curve_settings_dialog.finished.connect(self._curve_settings_closed)
self.curve_settings_dialog.show()

View File

@@ -1,12 +1,46 @@
import sys
from enum import Enum
from string import Template
from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer, Slot
from qtpy.QtCore import QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer
from qtpy.QtGui import QColor, QPainter, QPainterPath
class ProgressState(Enum):
NORMAL = "normal"
PAUSED = "paused"
INTERRUPTED = "interrupted"
COMPLETED = "completed"
@classmethod
def from_bec_status(cls, status: str) -> "ProgressState":
"""
Map a BEC status string (open, paused, aborted, halted, closed)
to the corresponding ProgressState.
Any unknown status falls back to NORMAL.
"""
mapping = {
"open": cls.NORMAL,
"paused": cls.PAUSED,
"aborted": cls.INTERRUPTED,
"halted": cls.PAUSED,
"closed": cls.COMPLETED,
}
return mapping.get(status.lower(), cls.NORMAL)
PROGRESS_STATE_COLORS = {
ProgressState.NORMAL: QColor("#2979ff"), # blue normal progress
ProgressState.PAUSED: QColor("#ffca28"), # orange/amber paused
ProgressState.INTERRUPTED: QColor("#ff5252"), # red interrupted
ProgressState.COMPLETED: QColor("#00e676"), # green finished
}
from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class BECProgressBar(BECWidget, QWidget):
@@ -21,6 +55,8 @@ class BECProgressBar(BECWidget, QWidget):
"set_minimum",
"label_template",
"label_template.setter",
"state",
"state.setter",
"_get_label",
]
ICON_NAME = "page_control"
@@ -48,27 +84,38 @@ class BECProgressBar(BECWidget, QWidget):
self._completed_color = accent_colors.success
self._border_color = QColor(50, 50, 50)
# Cornerrounding: base radius in pixels (autoreduced if bar is small)
self._corner_radius = 10
# Progressbar state handling
self._state = ProgressState.NORMAL
self._state_colors = dict(PROGRESS_STATE_COLORS)
# layout settings
self._padding_left_right = 10
self._value_animation = QPropertyAnimation(self, b"_progressbar_value")
self._value_animation.setDuration(200)
self._value_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
# label on top of the progress bar
self.center_label = QLabel(self)
self.center_label.setAlignment(Qt.AlignCenter)
self.center_label.setAlignment(Qt.AlignHCenter)
self.center_label.setStyleSheet("color: white;")
self.center_label.setMinimumSize(0, 0)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setContentsMargins(10, 0, 10, 0)
layout.setSpacing(0)
layout.addWidget(self.center_label)
layout.setAlignment(self.center_label, Qt.AlignCenter)
self.setLayout(layout)
self.update()
self._adjust_label_width()
@Property(str, doc="The template for the center label. Use $value, $maximum, and $percentage.")
@SafeProperty(
str, doc="The template for the center label. Use $value, $maximum, and $percentage."
)
def label_template(self):
"""
The template for the center label. Use $value, $maximum, and $percentage to insert the values.
@@ -83,10 +130,11 @@ class BECProgressBar(BECWidget, QWidget):
@label_template.setter
def label_template(self, template):
self._label_template = template
self._adjust_label_width()
self.set_value(self._user_value)
self.update()
@Property(float, designable=False)
@SafeProperty(float, designable=False)
def _progressbar_value(self):
"""
The current value of the progress bar.
@@ -106,8 +154,20 @@ class BECProgressBar(BECWidget, QWidget):
percentage=int((self.map_value(self._user_value) / self._maximum) * 100),
)
@Slot(float)
@Slot(int)
def _adjust_label_width(self):
"""
Reserve enough horizontal space for the center label so the widget
doesn't resize as the text grows during progress.
"""
template = Template(self._label_template)
sample_text = template.safe_substitute(
value=self._user_maximum, maximum=self._user_maximum, percentage=100
)
width = self.center_label.fontMetrics().horizontalAdvance(sample_text)
self.center_label.setFixedWidth(width)
@SafeSlot(float)
@SafeSlot(int)
def set_value(self, value):
"""
Set the value of the progress bar.
@@ -122,35 +182,88 @@ class BECProgressBar(BECWidget, QWidget):
self._target_value = self.map_value(value)
self._user_value = value
self.center_label.setText(self._update_template())
# Update state automatically unless paused or interrupted
if self._state not in (ProgressState.PAUSED, ProgressState.INTERRUPTED):
self._state = (
ProgressState.COMPLETED
if self._user_value >= self._user_maximum
else ProgressState.NORMAL
)
self.animate_progress()
@SafeProperty(object, doc="Current visual state of the progress bar.")
def state(self):
return self._state
@state.setter
def state(self, state):
"""
Set the visual state of the progress bar.
Args:
state(ProgressState | str): The state to set. Can be one of the
"""
if isinstance(state, str):
state = ProgressState(state.lower())
if not isinstance(state, ProgressState):
raise ValueError("state must be a ProgressState or its value")
self._state = state
self.update()
@SafeProperty(float, doc="Base corner radius in pixels (autoscaled down on small bars).")
def corner_radius(self) -> float:
return self._corner_radius
@corner_radius.setter
def corner_radius(self, radius: float):
self._corner_radius = max(0.0, radius)
self.update()
@SafeProperty(float)
def padding_left_right(self) -> float:
return self._padding_left_right
@padding_left_right.setter
def padding_left_right(self, padding: float):
self._padding_left_right = padding
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect().adjusted(10, 0, -10, -1)
rect = self.rect().adjusted(self._padding_left_right, 0, -self._padding_left_right, -1)
# Corner radius adapts to widget height so it never exceeds half the bars thickness
radius = min(self._corner_radius, rect.height() / 2)
# Draw background
painter.setBrush(self._background_color)
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(rect, 10, 10) # Rounded corners
painter.drawRoundedRect(rect, radius, radius) # Rounded corners
# Draw border
painter.setBrush(Qt.NoBrush)
painter.setPen(self._border_color)
painter.drawRoundedRect(rect, 10, 10)
painter.drawRoundedRect(rect, radius, radius)
# Determine progress color based on completion
if self._value >= self._maximum:
current_color = self._completed_color
# Determine progress colour based on current state
if self._state == ProgressState.PAUSED:
current_color = self._state_colors[ProgressState.PAUSED]
elif self._state == ProgressState.INTERRUPTED:
current_color = self._state_colors[ProgressState.INTERRUPTED]
elif self._state == ProgressState.COMPLETED or self._value >= self._maximum:
current_color = self._state_colors[ProgressState.COMPLETED]
else:
current_color = self._progress_color
current_color = self._state_colors[ProgressState.NORMAL]
# Set clipping region to preserve the background's rounded corners
progress_rect = rect.adjusted(
0, 0, int(-rect.width() + (self._value / self._maximum) * rect.width()), 0
)
clip_path = QPainterPath()
clip_path.addRoundedRect(QRectF(rect), 10, 10) # Clip to the background's rounded corners
clip_path.addRoundedRect(
QRectF(rect), radius, radius
) # Clip to the background's rounded corners
painter.setClipPath(clip_path)
# Draw progress bar
@@ -168,7 +281,7 @@ class BECProgressBar(BECWidget, QWidget):
self._value_animation.setEndValue(self._target_value)
self._value_animation.start()
@Property(float)
@SafeProperty(float)
def maximum(self):
"""
The maximum value of the progress bar.
@@ -182,7 +295,7 @@ class BECProgressBar(BECWidget, QWidget):
"""
self.set_maximum(maximum)
@Property(float)
@SafeProperty(float)
def minimum(self):
"""
The minimum value of the progress bar.
@@ -193,7 +306,7 @@ class BECProgressBar(BECWidget, QWidget):
def minimum(self, minimum: float):
self.set_minimum(minimum)
@Property(float)
@SafeProperty(float)
def initial_value(self):
"""
The initial value of the progress bar.
@@ -204,7 +317,7 @@ class BECProgressBar(BECWidget, QWidget):
def initial_value(self, value: float):
self.set_value(value)
@Slot(float)
@SafeSlot(float)
def set_maximum(self, maximum: float):
"""
Set the maximum value of the progress bar.
@@ -213,10 +326,11 @@ class BECProgressBar(BECWidget, QWidget):
maximum (float): The maximum value.
"""
self._user_maximum = maximum
self._adjust_label_width()
self.set_value(self._user_value) # Update the value to fit the new range
self.update()
@Slot(float)
@SafeSlot(float)
def set_minimum(self, minimum: float):
"""
Set the minimum value of the progress bar.

View File

@@ -0,0 +1,17 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.progress.scan_progressbar.scan_progress_bar_plugin import (
ScanProgressBarPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(ScanProgressBarPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1 @@
{'files': ['scan_progressbar.py']}

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
DOM_XML = """
<ui language='c++'>
<widget class='ScanProgressBar' name='scan_progress_bar'>
</widget>
</ui>
"""
class ScanProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = ScanProgressBar(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Utils"
def icon(self):
return designer_material_icon(ScanProgressBar.ICON_NAME)
def includeFile(self):
return "scan_progress_bar"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "ScanProgressBar"
def toolTip(self):
return "A progress bar that is hooked up to the scan progress of a scan."
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,320 @@
from __future__ import annotations
import enum
import os
import time
from typing import Literal
import numpy as np
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject, QTimer, Signal
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import ProgressState
logger = bec_logger.logger
class ProgressSource(enum.Enum):
"""
Enum to define the source of the progress.
"""
SCAN_PROGRESS = "scan_progress"
DEVICE_PROGRESS = "device_progress"
class ProgressTask(QObject):
"""
Class to store progress information.
Inspired by https://github.com/Textualize/rich/blob/master/rich/progress.py
"""
def __init__(self, parent: QWidget, value: float = 0, max_value: float = 0, done: bool = False):
super().__init__(parent=parent)
self.start_time = time.time()
self.done = done
self.value = value
self.max_value = max_value
self._elapsed_time = 0
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_elapsed_time)
self.timer.start(100) # update the elapsed time every 100 ms
def update(self, value: float, max_value: float, done: bool = False):
"""
Update the progress.
"""
self.max_value = max_value
self.done = done
self.value = value
if done:
self.timer.stop()
def update_elapsed_time(self):
"""
Update the time estimates. This is called every 100 ms by a QTimer.
"""
self._elapsed_time += 0.1
@property
def percentage(self) -> float:
"""float: Get progress of task as a percentage. If a None total was set, returns 0"""
if not self.max_value:
return 0.0
completed = (self.value / self.max_value) * 100.0
completed = min(100.0, max(0.0, completed))
return completed
@property
def speed(self) -> float:
"""Get the estimated speed in steps per second."""
if self._elapsed_time == 0:
return 0.0
return self.value / self._elapsed_time
@property
def frequency(self) -> float:
"""Get the estimated frequency in steps per second."""
if self.speed == 0:
return 0.0
return 1 / self.speed
@property
def time_elapsed(self) -> str:
# format the elapsed time to a string in the format HH:MM:SS
return self._format_time(int(self._elapsed_time))
@property
def remaining(self) -> float:
"""Get the estimated remaining steps."""
if self.done:
return 0.0
remaining = self.max_value - self.value
return remaining
@property
def time_remaining(self) -> str:
"""
Get the estimated remaining time in the format HH:MM:SS.
"""
if self.done or not self.speed or not self.remaining:
return self._format_time(0)
estimate = int(np.round(self.remaining / self.speed))
return self._format_time(estimate)
def _format_time(self, seconds: float) -> str:
"""
Format the time in seconds to a string in the format HH:MM:SS.
"""
return f"{seconds // 3600:02}:{(seconds // 60) % 60:02}:{seconds % 60:02}"
class ScanProgressBar(BECWidget, QWidget):
"""
Widget to display a progress bar that is hooked up to the scan progress of a scan.
If you want to manually set the progress, it is recommended to use the BECProgressbar or QProgressbar directly.
"""
ICON_NAME = "timelapse"
progress_started = Signal()
progress_finished = Signal()
def __init__(self, parent=None, client=None, config=None, gui_id=None, one_line_design=False):
super().__init__(parent=parent, client=client, config=config, gui_id=gui_id)
self.get_bec_shortcuts()
ui_file = os.path.join(
os.path.dirname(__file__),
"scan_progressbar_one_line.ui" if one_line_design else "scan_progressbar.ui",
)
self.ui = UILoader(self).loader(ui_file)
self.layout = QVBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.ui)
self.setLayout(self.layout)
self.progressbar = self.ui.progressbar
self.connect_to_queue()
self._progress_source = None
self.task = None
self.scan_number = None
self.progress_started.connect(lambda: print("Scan progress started"))
def connect_to_queue(self):
"""
Connect to the queue status signal.
"""
self.bec_dispatcher.connect_slot(self.on_queue_update, MessageEndpoints.scan_queue_status())
def set_progress_source(self, source: ProgressSource, device=None):
"""
Set the source of the progress.
"""
if self._progress_source == source:
self.update_source_label(source, device=device)
return
if self._progress_source is not None:
self.bec_dispatcher.disconnect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if self._progress_source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=device)
),
)
self._progress_source = source
self.bec_dispatcher.connect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=device)
),
)
self.update_source_label(source, device=device)
# self.progress_started.emit()
def update_source_label(self, source: ProgressSource, device=None):
scan_text = f"Scan {self.scan_number}" if self.scan_number is not None else "Scan"
text = scan_text if source == ProgressSource.SCAN_PROGRESS else f"Device {device}"
logger.info(f"Set progress source to {text}")
self.ui.source_label.setText(text)
@SafeSlot(dict, dict)
def on_progress_update(self, msg_content: dict, metadata: dict):
"""
Update the progress bar based on the progress message.
"""
value = msg_content["value"]
max_value = msg_content.get("max_value", 100)
done = msg_content.get("done", False)
status: Literal["open", "paused", "aborted", "halted", "closed"] = metadata.get(
"status", "open"
)
if self.task is None:
return
self.task.update(value, max_value, done)
self.update_labels()
self.progressbar.set_maximum(self.task.max_value)
self.progressbar.state = ProgressState.from_bec_status(status)
self.progressbar.set_value(self.task.value)
if done:
self.task = None
self.progress_finished.emit()
return
@SafeProperty(bool)
def show_elapsed_time(self):
return self.ui.elapsed_time_label.isVisible()
@show_elapsed_time.setter
def show_elapsed_time(self, value):
self.ui.elapsed_time_label.setVisible(value)
if hasattr(self.ui, "dash"):
self.ui.dash.setVisible(value)
@SafeProperty(bool)
def show_remaining_time(self):
return self.ui.remaining_time_label.isVisible()
@show_remaining_time.setter
def show_remaining_time(self, value):
self.ui.remaining_time_label.setVisible(value)
if hasattr(self.ui, "dash"):
self.ui.dash.setVisible(value)
@SafeProperty(bool)
def show_source_label(self):
return self.ui.source_label.isVisible()
@show_source_label.setter
def show_source_label(self, value):
self.ui.source_label.setVisible(value)
def update_labels(self):
"""
Update the labels based on the progress task.
"""
if self.task is None:
return
self.ui.elapsed_time_label.setText(self.task.time_elapsed)
self.ui.remaining_time_label.setText(self.task.time_remaining)
@SafeSlot(dict, dict, verify_sender=True)
def on_queue_update(self, msg_content, metadata):
"""
Update the progress bar based on the queue status.
"""
if not "queue" in msg_content:
return
primary_queue_info = msg_content["queue"].get("primary", {}).get("info", [])
if len(primary_queue_info) == 0:
return
scan_info = primary_queue_info[0]
if scan_info is None:
return
if scan_info.get("status").lower() == "running" and self.task is None:
self.task = ProgressTask(parent=self)
self.progress_started.emit()
active_request_block = scan_info.get("active_request_block", {})
if active_request_block is None:
return
self.scan_number = active_request_block.get("scan_number")
report_instructions = active_request_block.get("report_instructions", [])
if not report_instructions:
return
# for now, let's just use the first instruction
instruction = report_instructions[0]
if "scan_progress" in instruction:
self.set_progress_source(ProgressSource.SCAN_PROGRESS)
elif "device_progress" in instruction:
device = instruction["device_progress"][0]
self.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
def cleanup(self):
if self.task is not None:
self.task.timer.stop()
self.close()
self.deleteLater()
if self._progress_source is not None:
self.bec_dispatcher.disconnect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if self._progress_source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=self._progress_source.value)
),
)
self.progressbar.close()
self.progressbar.deleteLater()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
bec_logger.disabled_modules = ["bec_lib"]
app = QApplication([])
widget = ScanProgressBar()
widget.show()
app.exec_()

View File

@@ -0,0 +1,141 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>211</width>
<height>60</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>60</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>1</number>
</property>
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<layout class="QHBoxLayout" name="source_layout">
<item>
<widget class="QLabel" name="source_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>Scan</string>
</property>
</widget>
</item>
<item>
<spacer name="source_spacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>10</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="BECProgressBar" name="progressbar">
<property name="padding_left_right" stdset="0">
<double>2.000000000000000</double>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="timer_layout">
<item>
<widget class="QLabel" name="remaining_time_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>0:00:00</string>
</property>
</widget>
</item>
<item>
<spacer name="timer_spacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="elapsed_time_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>0:00:00</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>BECProgressBar</class>
<extends>QWidget</extends>
<header>bec_progress_bar</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>328</width>
<height>24</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>24</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>24</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2" stretch="0,1,0">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>2</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="QLabel" name="source_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>Scan</string>
</property>
</widget>
</item>
<item>
<widget class="BECProgressBar" name="progressbar">
<property name="minimumSize">
<size>
<width>30</width>
<height>0</height>
</size>
</property>
<property name="padding_left_right" stdset="0">
<double>5.000000000000000</double>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="remaining_time_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>0:00:00</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="dash">
<property name="text">
<string>-</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="elapsed_time_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>0:00:00</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>BECProgressBar</class>
<extends>QWidget</extends>
<header>bec_progress_bar</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -8,7 +8,7 @@ from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import QMimeData, QSize, Qt, Signal
from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QApplication, QHBoxLayout, QToolButton, QWidget
from qtpy.QtWidgets import QApplication, QHBoxLayout, QTabWidget, QToolButton, QVBoxLayout, QWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
@@ -18,6 +18,9 @@ from bec_widgets.widgets.services.device_browser.device_item.device_config_dialo
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
)
from bec_widgets.widgets.services.device_browser.device_item.device_signal_display import (
SignalDisplay,
)
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtGui import QMouseEvent
@@ -38,10 +41,25 @@ class DeviceItem(ExpandableGroupFrame):
self._expanded_first_time = False
self._data = None
self.device = device
layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.set_layout(layout)
self._layout = QHBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._tab_widget = QTabWidget(tabShape=QTabWidget.TabShape.Rounded)
self._tab_widget.setDocumentMode(True)
self._layout.addWidget(self._tab_widget)
self.set_layout(self._layout)
self._form_page = QWidget()
self._form_page_layout = QVBoxLayout()
self._form_page.setLayout(self._form_page_layout)
self._signal_page = QWidget()
self._signal_page_layout = QVBoxLayout()
self._signal_page.setLayout(self._signal_page_layout)
self._tab_widget.addTab(self._form_page, "Configuration")
self._tab_widget.addTab(self._signal_page, "Signals")
self.adjustSize()
def _create_title_layout(self, title: str, icon: str):
@@ -64,7 +82,9 @@ class DeviceItem(ExpandableGroupFrame):
if not self.expanded and not self._expanded_first_time:
self._expanded_first_time = True
self.form = DeviceConfigForm(parent=self, pretty_display=True)
self._contents.layout().addWidget(self.form)
self._form_page_layout.addWidget(self.form)
self.signals = SignalDisplay(parent=self, device=self.device)
self._signal_page_layout.addWidget(self.signals)
self._reload_config()
self.broadcast_size_hint.emit(self.sizeHint())
super().switch_expanded_state()

View File

@@ -0,0 +1,102 @@
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QLabel, QToolButton, QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.containers.dock.dock import BECDock
from bec_widgets.widgets.utility.signal_label.signal_label import SignalLabel
class SignalDisplay(BECWidget, QWidget):
RPC = False
def __init__(
self,
client=None,
device: str = "",
config: ConnectionConfig = None,
gui_id: str | None = None,
theme_update: bool = False,
parent_dock: BECDock | None = None,
**kwargs,
):
"""A widget to display all the signals from a given device, and allow getting
a fresh reading."""
super().__init__(client, config, gui_id, theme_update, parent_dock, **kwargs)
self.get_bec_shortcuts()
self._layout = QVBoxLayout()
self.setLayout(self._layout)
self._content = QWidget()
self._layout.addWidget(self._content)
self._device = device
self.device = device
@SafeSlot()
def _refresh(self):
if self.device in self.dev:
self.dev.get(self.device).read(cached=False)
self.dev.get(self.device).read_configuration(cached=False)
def _add_refresh_button(self):
button_holder = QWidget()
button_holder.setLayout(QHBoxLayout())
button_holder.layout().setAlignment(Qt.AlignmentFlag.AlignRight)
button_holder.layout().setContentsMargins(0, 0, 0, 0)
refresh_button = QToolButton()
refresh_button.setIcon(
material_icon(icon_name="refresh", size=(20, 20), convert_to_pixmap=False)
)
refresh_button.clicked.connect(self._refresh)
button_holder.layout().addWidget(refresh_button)
self._content_layout.addWidget(button_holder)
def _populate(self):
self._content.deleteLater()
self._content = QWidget()
self._layout.addWidget(self._content)
self._content_layout = QVBoxLayout()
self._content_layout.setContentsMargins(0, 0, 0, 0)
self._content.setLayout(self._content_layout)
self._add_refresh_button()
if self._device in self.dev:
for sig in self.dev[self.device]._info.get("signals", {}).keys():
self._content_layout.addWidget(
SignalLabel(
device=self._device,
signal=sig,
show_select_button=False,
show_default_units=True,
)
)
self._content_layout.addStretch(1)
else:
self._content_layout.addWidget(
QLabel(f"Device {self.device} not found in device manager!")
)
@SafeProperty(str)
def device(self):
return self._device
@device.setter
def device(self, value: str):
self._device = value
self._populate()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
app = QApplication(sys.argv)
set_theme("light")
widget = SignalDisplay(device="samx")
widget.show()
sys.exit(app.exec_())

View File

@@ -16,7 +16,6 @@ from qtpy.QtWidgets import (
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QToolButton,
QVBoxLayout,
QWidget,
@@ -180,6 +179,7 @@ class SignalLabel(BECWidget, QWidget):
self._custom_units: str = custom_units
self._show_default_units: bool = show_default_units
self._decimal_places = 3
self._dtype = None
self._show_hinted_signals: bool = True
self._show_normal_signals: bool = False
@@ -241,8 +241,10 @@ class SignalLabel(BECWidget, QWidget):
"""Subscribe to the Redis topic for the device to display"""
if not self._connected and self._device and self._device in self.dev:
self._connected = True
self._readback_endpoint = MessageEndpoints.device_readback(self._device)
self.bec_dispatcher.connect_slot(self.on_device_readback, self._readback_endpoint)
self._read_endpoint = MessageEndpoints.device_read(self._device)
self._read_config_endpoint = MessageEndpoints.device_read_configuration(self._device)
self.bec_dispatcher.connect_slot(self.on_device_readback, self._read_endpoint)
self.bec_dispatcher.connect_slot(self.on_device_readback, self._read_config_endpoint)
self._manual_read()
self.set_display_value(self._value)
@@ -250,7 +252,8 @@ class SignalLabel(BECWidget, QWidget):
"""Unsubscribe from the Redis topic for the device to display"""
if self._connected:
self._connected = False
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._readback_endpoint)
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._read_endpoint)
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._read_config_endpoint)
def _manual_read(self):
if self._device is None or not isinstance(
@@ -259,8 +262,13 @@ class SignalLabel(BECWidget, QWidget):
self._units = ""
self._value = "__"
return
signal: Signal = (
getattr(device, self.signal, None) if isinstance(device, Device) else device
signal, info = (
(
getattr(device, self.signal, None),
device._info.get("signals", {}).get(self._signal, {}).get("describe", {}),
)
if isinstance(device, Device)
else (device, device.describe().get(self._device))
)
if not isinstance(signal, Signal): # Avoid getting other attributes of device, e.g. methods
signal = None
@@ -269,7 +277,8 @@ class SignalLabel(BECWidget, QWidget):
self._value = "__"
return
self._value = signal.get()
self._units = signal.get_device_config().get("egu", "")
self._units = info.get("egu", "")
self._dtype = info.get("dtype", "float")
@SafeSlot(dict, dict)
def on_device_readback(self, msg: dict, metadata: dict) -> None:
@@ -278,8 +287,10 @@ class SignalLabel(BECWidget, QWidget):
"""
try:
signal_to_read = self._patch_hinted_signal()
self._value = msg["signals"][signal_to_read]["value"]
self.set_display_value(self._value)
_value = msg["signals"].get(signal_to_read, {}).get("value")
if _value is not None:
self._value = _value
self.set_display_value(self._value)
except Exception as e:
self._display.setText("ERROR!")
self._display.setToolTip(
@@ -401,7 +412,10 @@ class SignalLabel(BECWidget, QWidget):
if self._decimal_places == 0:
return value
try:
return f"{float(value):0.{self._decimal_places}f}"
if self._dtype in ("integer", "float"):
return f"{float(value):0.{self._decimal_places}f}"
else:
return str(value)
except ValueError:
return value

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.16.2"
version = "2.19.1"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -13,15 +13,15 @@ classifiers = [
"Topic :: Scientific/Engineering",
]
dependencies = [
"bec_ipython_client>=3.38, <=4.0", # needed for jupyter console
"bec_lib>=3.38, <=4.0",
"bec_ipython_client>=3.42.4, <=4.0", # needed for jupyter console
"bec_lib>=3.42.4, <=4.0",
"bec_qthemes~=0.7, >=0.7",
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"pydantic~=2.0",
"pyqtgraph~=0.13",
"PySide6~=6.8.2",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
]

View File

@@ -79,7 +79,7 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
gui = connected_client_gui_obj
dock_area = gui.bec
# Number of top level widgets, should be 4
top_level_widgets_count = 4
top_level_widgets_count = 12
assert len(gui._server_registry) == top_level_widgets_count
# Number of widgets with parent_id == None, should be 2
widgets = [

View File

@@ -12,12 +12,11 @@ may not be created immediately after the rpc call is made.
from __future__ import annotations
import random
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
import numpy as np
import pytest
from bec_widgets.cli.client import BECDockArea
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
PYTEST_TIMEOUT = 50
@@ -321,20 +320,20 @@ def test_widgets_e2e_signal_combobox(qtbot, connected_client_gui_obj, random_gen
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox)
dock: client.BECDock
_, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox)
widget: client.SignalComboBox
widget.set_device("samx")
info = bec.device_manager.devices.samx._info["signals"]
assert widget.signals == [
"readback",
"setpoint",
"motor_is_moving",
"velocity",
"acceleration",
"tolerance",
["samx (readback)", info.get("readback")],
["setpoint", info.get("setpoint")],
["motor_is_moving", info.get("motor_is_moving")],
["velocity", info.get("velocity")],
["acceleration", info.get("acceleration")],
["tolerance", info.get("tolerance")],
]
widget.set_signal("readback")
widget.set_signal("samx (readback)")
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)

View File

@@ -1,7 +1,10 @@
import numpy as np
import pytest
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import (
BECProgressBar,
ProgressState,
)
@pytest.fixture
@@ -33,3 +36,23 @@ def test_progressbar_label(progressbar):
progressbar.label_template = "Test: $value"
progressbar.set_value(50)
assert progressbar.center_label.text() == "Test: 50"
def test_progress_state_from_bec_status():
"""ProgressState.from_bec_status() maps BEC literals correctly."""
mapping = {
"open": ProgressState.NORMAL,
"paused": ProgressState.PAUSED,
"aborted": ProgressState.INTERRUPTED,
"halted": ProgressState.PAUSED,
"closed": ProgressState.COMPLETED,
"UNKNOWN": ProgressState.NORMAL, # fallback
}
for text, expected in mapping.items():
assert ProgressState.from_bec_status(text) is expected
def test_progressbar_state_setter(progressbar):
"""Setting .state reflects internally."""
progressbar.state = ProgressState.PAUSED
assert progressbar.state is ProgressState.PAUSED

View File

@@ -3,6 +3,7 @@ from unittest import mock
import pytest
from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QTabWidget
from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
@@ -86,8 +87,13 @@ def test_device_item_expansion(device_browser, qtbot):
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
form = widget._contents.layout().itemAt(0).widget()
qtbot.waitUntil(lambda: isinstance(form, DeviceConfigForm), timeout=500)
tab_widget: QTabWidget = widget._contents.layout().itemAt(0).widget()
qtbot.waitUntil(lambda: tab_widget.widget(0) is not None, timeout=100)
qtbot.waitUntil(
lambda: isinstance(tab_widget.widget(0).layout().itemAt(0).widget(), DeviceConfigForm),
timeout=100,
)
form = tab_widget.widget(0).layout().itemAt(0).widget()
assert widget.expanded
assert (name_field := form.widget_dict.get("name")) is not None
qtbot.waitUntil(lambda: name_field.getValue() == "samx", timeout=500)

View File

@@ -94,18 +94,6 @@ def test_device_signal_qproperties(device_signal_base):
assert device_signal_base._signal_filter == {Kind.config, Kind.normal}
def test_device_signal_set_device(device_signal_base):
"""Test if the set_device method works correctly"""
device_signal_base.include_hinted_signals = True
device_signal_base.set_device("samx")
assert device_signal_base.device == "samx"
assert device_signal_base.signals == ["readback"]
device_signal_base.include_normal_signals = True
assert device_signal_base.signals == ["readback", "setpoint"]
device_signal_base.include_config_signals = True
assert device_signal_base.signals == ["readback", "setpoint", "velocity"]
def test_signal_combobox(qtbot, device_signal_combobox):
"""Test the signal_combobox"""
container = []
@@ -120,17 +108,25 @@ def test_signal_combobox(qtbot, device_signal_combobox):
device_signal_combobox.include_config_signals = True
assert device_signal_combobox.signals == []
device_signal_combobox.set_device("samx")
assert device_signal_combobox.signals == ["readback", "setpoint", "velocity"]
samx = device_signal_combobox.dev.samx
assert device_signal_combobox.signals == [
("samx (readback)", samx._info["signals"].get("readback")),
("setpoint", samx._info["signals"].get("setpoint")),
("velocity", samx._info["signals"].get("velocity")),
]
qtbot.wait(100)
assert container == ["samx"]
assert container == ["samx (readback)"]
# Set the type of class from the FakeDevice to Signal
fake_signal = FakeSignal(name="fake_signal")
fake_signal = FakeSignal(name="fake_signal", info={"device_info": {"signals": {}}})
device_signal_combobox.client.device_manager.add_devices([fake_signal])
device_signal_combobox.set_device("fake_signal")
assert device_signal_combobox.signals == ["fake_signal"]
fake_signal = device_signal_combobox.dev.fake_signal
assert device_signal_combobox.signals == [
("fake_signal", fake_signal._info["signals"].get("fake_signal", {}))
]
assert device_signal_combobox._config_signals == []
assert device_signal_combobox._normal_signals == []
assert device_signal_combobox._hinted_signals == ["fake_signal"]
assert device_signal_combobox._hinted_signals == [("fake_signal", {})]
def test_signal_lineedit(device_signal_line_edit):

View File

@@ -102,7 +102,34 @@ def test_launch_window_launch_plugin_auto_update(bec_launch_window):
[
({}, False),
({"launcher": mock.MagicMock()}, False),
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, True),
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, False),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress": mock.MagicMock(),
},
False,
),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress_simple": mock.MagicMock(),
"scan_progress_full": mock.MagicMock(),
},
False,
),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress_simple": mock.MagicMock(),
"scan_progress_full": mock.MagicMock(),
"hover_widget": mock.MagicMock(),
},
True,
),
],
)
def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide):
@@ -132,7 +159,34 @@ def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide):
[
({}, True),
({"launcher": mock.MagicMock()}, True),
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, False),
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, True),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress": mock.MagicMock(),
},
True,
),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress_simple": mock.MagicMock(),
"scan_progress_full": mock.MagicMock(),
},
True,
),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress_simple": mock.MagicMock(),
"scan_progress_full": mock.MagicMock(),
"hover_widget": mock.MagicMock(),
},
False,
),
],
)
def test_launch_window_closes(bec_launch_window, connections, close_called):

View File

@@ -1,8 +1,14 @@
import webbrowser
import pytest
from qtpy.QtWidgets import QFrame
from qtpy.QtCore import QEvent, QPoint, QPointF
from qtpy.QtGui import QEnterEvent
from qtpy.QtWidgets import QApplication, QFrame, QLabel
from bec_widgets.widgets.containers.main_window.addons.hover_widget import (
HoverWidget,
WidgetTooltip,
)
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
@@ -187,3 +193,101 @@ def test_bec_weblinks(monkeypatch):
"https://bec.readthedocs.io/projects/bec-widgets/en/latest/",
"https://gitlab.psi.ch/groups/bec/-/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
def test_hover_widget_tooltip(qtbot):
"""
After a HoverWidget is closed, its WidgetTooltip must be gone.
"""
simple = QLabel("Hover me")
full = QLabel("Full details")
hover = create_widget(qtbot, HoverWidget, simple=simple, full=full)
assert hover._simple is simple
assert hover._full is full
assert hover._tooltip is None
def test_widget_tooltip_show_and_hide(qtbot):
"""
WidgetTooltip should appear when show_above is called and hide on Leave.
"""
full_lbl = QLabel("Standalone tooltip content")
tooltip = create_widget(qtbot, WidgetTooltip, content=full_lbl)
# Show above an arbitrary point
pos = QPoint(200, 200)
tooltip.show_above(pos)
assert tooltip.isVisible()
# Send a synthetic Leave event
QApplication.sendEvent(tooltip, QEvent(QEvent.Leave))
qtbot.waitUntil(lambda: not tooltip.isVisible(), timeout=500)
assert not tooltip.isVisible()
def test_hover_widget_mouse_events(qtbot):
"""
Verify that HoverWidget responds correctly to Enter, MouseMove, and Leave
events, keeping the tooltip visible only while the pointer is inside.
"""
simple = QLabel("Hovertarget")
full = QLabel("Fullview")
hover = create_widget(qtbot, HoverWidget, simple=simple, full=full)
local = QPointF(hover.rect().center()) # inside widget
scene = QPointF(hover.mapTo(hover.window(), local.toPoint()))
global_ = QPointF(hover.mapToGlobal(local.toPoint()))
enter_event = QEnterEvent(local, scene, global_)
hover.enterEvent(event=enter_event)
qtbot.wait(200)
assert hover._tooltip is not None
assert hover._tooltip.isVisible()
assert hover._tooltip.content is full

View File

@@ -0,0 +1,336 @@
from unittest import mock
import numpy as np
import pytest
from bec_lib import messages
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import (
BECProgressBar,
ProgressState,
)
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import (
ProgressSource,
ProgressTask,
ScanProgressBar,
)
from .client_mocks import mocked_client
@pytest.fixture
def scan_progressbar(qtbot, mocked_client):
widget = ScanProgressBar(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_progress_task_basic():
"""percentage, remaining, and formatted time helpers behave as expected."""
task = ProgressTask(parent=None, value=50, max_value=100, done=False)
task.timer.stop() # we dont want the timer ticking in tests
task._elapsed_time = 10 # simulate 10 s elapsed
# 50 / 100 ⇒ 50 %
assert task.percentage == 50
# speed = value / elapsed = 5 steps / s
assert np.isclose(task.speed, 5)
# remaining steps = 50 ; time_remaining ≈ 10 s
assert task.remaining == 50
assert task.time_remaining == "00:00:10"
# time_elapsed formatting
assert task.time_elapsed == "00:00:10"
def test_scan_progressbar_initialization(scan_progressbar):
assert isinstance(scan_progressbar, ScanProgressBar)
assert isinstance(scan_progressbar.progressbar, BECProgressBar)
def test_update_labels_content(scan_progressbar):
"""update_labels() reflects ProgressTask time strings on the UI."""
# fabricate a task with known timings
task = ProgressTask(parent=scan_progressbar, value=30, max_value=100, done=False)
task.timer.stop()
task._elapsed_time = 50
scan_progressbar.task = task
scan_progressbar.update_labels()
assert scan_progressbar.ui.elapsed_time_label.text() == "00:00:50"
assert scan_progressbar.ui.remaining_time_label.text() == "00:01:57"
def test_on_progress_update(qtbot, scan_progressbar):
"""
on_progress_update() should forward new values to the embedded
BECProgressBar and keep ProgressTask in sync.
"""
task = ProgressTask(parent=scan_progressbar, value=0, max_value=100, done=False)
task.timer.stop()
scan_progressbar.task = task
msg = {"value": 20, "max_value": 100, "done": False}
scan_progressbar.on_progress_update(msg, metadata={"status": "open"})
qtbot.wait(200)
bar = scan_progressbar.progressbar
assert bar._user_value == 20
assert bar._user_maximum == 100
# state reflects BEC status
assert bar.state is ProgressState.NORMAL
@pytest.mark.parametrize(
"status, value, max_val, expected_state",
[
("open", 10, 100, ProgressState.NORMAL),
("paused", 25, 100, ProgressState.PAUSED),
("aborted", 30, 100, ProgressState.INTERRUPTED),
("halted", 40, 100, ProgressState.PAUSED),
("closed", 100, 100, ProgressState.COMPLETED),
],
)
def test_state_mapping_during_updates(
qtbot, scan_progressbar, status, value, max_val, expected_state
):
"""ScanProgressBar should translate BEC status → ProgressState consistently."""
task = ProgressTask(parent=scan_progressbar, value=0, max_value=max_val, done=False)
task.timer.stop()
scan_progressbar.task = task
scan_progressbar.on_progress_update(
{"value": value, "max_value": max_val, "done": status == "closed"},
metadata={"status": status},
)
assert scan_progressbar.progressbar.state is expected_state
def test_source_label_updates(scan_progressbar):
"""update_source_label() renders correct text for both progress sources."""
# device progress
scan_progressbar.update_source_label(ProgressSource.DEVICE_PROGRESS, device="motor")
assert scan_progressbar.ui.source_label.text() == "Device motor"
# scan progress (needs a scan_number for deterministic text)
scan_progressbar.scan_number = 5
scan_progressbar.update_source_label(ProgressSource.SCAN_PROGRESS)
assert scan_progressbar.ui.source_label.text() == "Scan 5"
def test_set_progress_source_connections(scan_progressbar, monkeypatch):
""" """
from bec_lib.endpoints import MessageEndpoints
connect_calls = []
disconnect_calls = []
def fake_connect(slot, endpoint):
connect_calls.append(endpoint)
def fake_disconnect(slot, endpoint):
disconnect_calls.append(endpoint)
# Patch dispatcher methods
monkeypatch.setattr(scan_progressbar.bec_dispatcher, "connect_slot", fake_connect)
monkeypatch.setattr(scan_progressbar.bec_dispatcher, "disconnect_slot", fake_disconnect)
# switch to SCAN_PROGRESS
scan_progressbar.scan_number = 7
scan_progressbar.set_progress_source(ProgressSource.SCAN_PROGRESS)
assert scan_progressbar._progress_source == ProgressSource.SCAN_PROGRESS
assert scan_progressbar.ui.source_label.text() == "Scan 7"
assert connect_calls[-1] == MessageEndpoints.scan_progress()
assert disconnect_calls == []
# switch to DEVICE_PROGRESS
device = "motor"
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
assert scan_progressbar._progress_source == ProgressSource.DEVICE_PROGRESS
assert scan_progressbar.ui.source_label.text() == f"Device {device}"
assert connect_calls[-1] == MessageEndpoints.device_progress(device=device)
assert disconnect_calls == [MessageEndpoints.scan_progress()]
# calling again with the SAME source should not add new connect calls
prev_connect_count = len(connect_calls)
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
assert len(connect_calls) == prev_connect_count, "No extra connect made for same source"
def test_progressbar_queue_update(scan_progressbar):
"""
Test that an empty queue update does not change the progress source.
"""
msg = messages.ScanQueueStatusMessage(queue={"primary": {"info": [], "status": "RUNNING"}})
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_not_called()
def test_progressbar_queue_update_with_scan(scan_progressbar):
"""
Test that a queue update with a scan changes the progress source to SCAN_PROGRESS.
"""
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": {
"info": [
{
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
"status": "RUNNING",
"active_request_block": {
"msg": messages.ScanQueueMessage(
metadata={
"file_suffix": None,
"file_directory": None,
"user_metadata": {"sample_name": ""},
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
},
scan_type="line_scan",
parameter={
"args": {"samx": [-10.0, 10.0]},
"kwargs": {
"steps": 20,
"relative": False,
"exp_time": 0.1,
"burst_at_each_point": 1,
"system_config": {
"file_suffix": None,
"file_directory": None,
},
},
},
queue="primary",
),
"scan_number": 1,
"report_instructions": [{"scan_progress": 20}],
},
}
],
"status": "RUNNING",
}
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_called_once_with(ProgressSource.SCAN_PROGRESS)
def test_progressbar_queue_update_with_device(scan_progressbar):
"""
Test that a queue update with a device changes the progress source to DEVICE_PROGRESS.
"""
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": {
"info": [
{
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
"status": "RUNNING",
"active_request_block": {
"msg": messages.ScanQueueMessage(
metadata={
"file_suffix": None,
"file_directory": None,
"user_metadata": {"sample_name": ""},
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
},
scan_type="line_scan",
parameter={
"args": {"samx": [-10.0, 10.0]},
"kwargs": {
"steps": 20,
"relative": False,
"exp_time": 0.1,
"burst_at_each_point": 1,
"system_config": {
"file_suffix": None,
"file_directory": None,
},
},
},
queue="primary",
),
"scan_number": 1,
"report_instructions": [{"device_progress": ["samx"]}],
},
}
],
"status": "RUNNING",
}
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_called_once_with(ProgressSource.DEVICE_PROGRESS, device="samx")
def test_progressbar_queue_update_with_no_scan_or_device(scan_progressbar):
"""
Test that a queue update with neither scan nor device does not change the progress source.
"""
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": {
"info": [
{
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
"status": "RUNNING",
"active_request_block": {
"msg": messages.ScanQueueMessage(
metadata={
"file_suffix": None,
"file_directory": None,
"user_metadata": {"sample_name": ""},
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
},
scan_type="line_scan",
parameter={
"args": {"samx": [-10.0, 10.0]},
"kwargs": {
"steps": 20,
"relative": False,
"exp_time": 0.1,
"burst_at_each_point": 1,
"system_config": {
"file_suffix": None,
"file_directory": None,
},
},
},
queue="primary",
),
"scan_number": 1,
},
}
],
"status": "RUNNING",
}
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_not_called()

View File

@@ -121,11 +121,13 @@ def test_custom_label(signal_label: SignalLabel, qtbot):
def test_units_in_display(signal_label: SignalLabel, qtbot):
signal_label._value = "1.8"
signal_label._dtype = "float"
signal_label.custom_units = "Mfurlong μfortnight⁻¹"
assert signal_label._display.text() == "1.800 Mfurlong μfortnight⁻¹"
def test_decimal_places(signal_label: SignalLabel, qtbot):
signal_label._dtype = "float"
signal_label.decimal_places = 2
signal_label.set_display_value("123.456")
assert signal_label._display.text() == "123.46 m/s"
@@ -226,6 +228,7 @@ def test_handle_readback(signal_label: SignalLabel, qtbot):
signal_label.device = "samx"
signal_label.signal = "readback"
signal_label.custom_units = "μm"
signal_label._dtype = "float"
signal_label.on_device_readback({"random": {"stuff": "in", "corrupted": "reading"}}, {})
assert signal_label._display.text() == "ERROR!"
assert "Error processing incoming reading" in signal_label._display.toolTip()