mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-15 21:20:55 +02:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cba4d47f76 | ||
| 9f3dcc3ab3 | |||
| 57f75bd4d5 | |||
| 4456297beb | |||
|
|
ae26b43fb1 | ||
| 7484f5160c | |||
| 6421050116 | |||
|
|
5a137d1219 | ||
| d5a40dabc7 | |||
| f3da6e959e | |||
| 3a103410e7 | |||
| 3378051250 |
2
.github/workflows/formatter.yml
vendored
2
.github/workflows/formatter.yml
vendored
@@ -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
|
||||
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -1,6 +1,51 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.19.2 (2025-06-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scan_control**: Scan parameters fetched from the scan_history, fix #707
|
||||
([`4456297`](https://github.com/bec-project/bec_widgets/commit/4456297beb940b147882f96caee6fb19aaf93c73))
|
||||
|
||||
### Build System
|
||||
|
||||
- Bec_lib 3.44 required
|
||||
([`9f3dcc3`](https://github.com/bec-project/bec_widgets/commit/9f3dcc3ab30a2c238ffffa8d594735ccaf6f1ca4))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **scan_control**: Request_last_executed_scan_parameters logic adjusted
|
||||
([`57f75bd`](https://github.com/bec-project/bec_widgets/commit/57f75bd4d506ca4d8dc982f3051d0d4c29b0d41c))
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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) <= 2
|
||||
return len(remaining_connections) <= 4
|
||||
|
||||
def _turn_off_the_lights(self, connections: dict):
|
||||
"""
|
||||
|
||||
@@ -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_())
|
||||
@@ -3,15 +3,7 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import (
|
||||
QAbstractAnimation,
|
||||
QEasingCurve,
|
||||
QEvent,
|
||||
QPropertyAnimation,
|
||||
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,
|
||||
@@ -30,6 +22,7 @@ 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
|
||||
@@ -96,33 +89,60 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self._add_separator()
|
||||
|
||||
# Centre: Client‑info label (stretch=1 so it expands)
|
||||
self._client_info_label = ScrollLabel()
|
||||
self._client_info_label.setAlignment(
|
||||
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
self.status_bar.addWidget(self._client_info_label, 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._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
|
||||
)
|
||||
# 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("")
|
||||
)
|
||||
|
||||
################################################################################
|
||||
# Progress‑bar helpers
|
||||
def _add_scan_progress_bar(self):
|
||||
|
||||
# --- Progress bar -------------------------------------------------
|
||||
# Scan progress bar minimalistic design setup
|
||||
self._scan_progress_bar = ScanProgressBar(self, one_line_design=True)
|
||||
self._scan_progress_bar.show_elapsed_time = False
|
||||
self._scan_progress_bar.show_remaining_time = False
|
||||
self._scan_progress_bar.show_source_label = False
|
||||
self._scan_progress_bar.progressbar.label_template = ""
|
||||
self._scan_progress_bar.progressbar.setFixedHeight(8)
|
||||
self._scan_progress_bar.progressbar.setFixedWidth(80)
|
||||
# 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)
|
||||
@@ -133,7 +153,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
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_bar)
|
||||
self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover)
|
||||
|
||||
# Set Size
|
||||
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH
|
||||
@@ -152,8 +172,8 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
|
||||
|
||||
# Show / hide behaviour
|
||||
self._scan_progress_bar.progress_started.connect(self._show_scan_progress_bar)
|
||||
self._scan_progress_bar.progress_finished.connect(self._delay_hide_scan_progress_bar)
|
||||
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():
|
||||
@@ -342,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():
|
||||
@@ -393,10 +413,20 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
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._scan_progress_bar.close()
|
||||
self._scan_progress_bar.deleteLater()
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -203,35 +203,37 @@ class ScanControl(BECWidget, QWidget):
|
||||
"""
|
||||
Requests the last executed scan parameters from BEC and restores them to the scan control widget.
|
||||
"""
|
||||
enabled = self.toggle.checked
|
||||
current_scan = self.comboBox_scan_selection.currentText()
|
||||
if enabled:
|
||||
history = self.client.connector.lrange(MessageEndpoints.scan_queue_history(), 0, -1)
|
||||
self.last_scan_found = False
|
||||
if not self.toggle.checked:
|
||||
return
|
||||
|
||||
for scan in history:
|
||||
scan_name = scan.content["info"]["request_blocks"][-1]["msg"].content["scan_type"]
|
||||
if scan_name == current_scan:
|
||||
args_dict = scan.content["info"]["request_blocks"][-1]["msg"].content[
|
||||
"parameter"
|
||||
]["args"]
|
||||
args_list = []
|
||||
for key, value in args_dict.items():
|
||||
args_list.append(key)
|
||||
args_list.extend(value)
|
||||
if len(args_list) > 1 and self.arg_box is not None:
|
||||
self.arg_box.set_parameters(args_list)
|
||||
kwargs = scan.content["info"]["request_blocks"][-1]["msg"].content["parameter"][
|
||||
"kwargs"
|
||||
]
|
||||
if kwargs and self.kwarg_boxes:
|
||||
for box in self.kwarg_boxes:
|
||||
box.set_parameters(kwargs)
|
||||
self.last_scan_found = True
|
||||
break
|
||||
else:
|
||||
self.last_scan_found = False
|
||||
else:
|
||||
self.last_scan_found = False
|
||||
current_scan = self.comboBox_scan_selection.currentText()
|
||||
history = self.client.connector.xread(
|
||||
MessageEndpoints.scan_history(), from_start=True, user_id=self.object_name
|
||||
)
|
||||
|
||||
for scan in history:
|
||||
scan_data = scan.get("data")
|
||||
if not scan_data:
|
||||
continue
|
||||
|
||||
if scan_data.scan_name != current_scan:
|
||||
continue
|
||||
|
||||
ri = getattr(scan_data, "request_inputs", {}) or {}
|
||||
args_list = ri.get("arg_bundle", [])
|
||||
if args_list and self.arg_box:
|
||||
self.arg_box.set_parameters(args_list)
|
||||
|
||||
inputs = ri.get("inputs", {})
|
||||
kwargs = ri.get("kwargs", {})
|
||||
merged = {**inputs, **kwargs}
|
||||
if merged and self.kwarg_boxes:
|
||||
for box in self.kwarg_boxes:
|
||||
box.set_parameters(merged)
|
||||
|
||||
self.last_scan_found = True
|
||||
break
|
||||
|
||||
@SafeProperty(str)
|
||||
def current_scan(self):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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_())
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.18.0"
|
||||
version = "2.19.2"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -14,7 +14,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"bec_ipython_client>=3.42.4, <=4.0", # needed for jupyter console
|
||||
"bec_lib>=3.42.4, <=4.0",
|
||||
"bec_lib>=3.44, <=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
|
||||
|
||||
@@ -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 = 8
|
||||
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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -109,6 +109,25 @@ def test_launch_window_launch_plugin_auto_update(bec_launch_window):
|
||||
"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,
|
||||
),
|
||||
],
|
||||
@@ -147,6 +166,25 @@ def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide):
|
||||
"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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
@@ -228,3 +234,60 @@ def test_scan_progress_bar_hide_animation(qtbot, bec_main_window):
|
||||
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("Hover‑target")
|
||||
full = QLabel("Full‑view")
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.messages import AvailableResourceMessage, ScanQueueHistoryMessage, ScanQueueMessage
|
||||
from bec_lib.messages import AvailableResourceMessage, ScanHistoryMessage
|
||||
from qtpy.QtCore import QModelIndex, Qt
|
||||
|
||||
from bec_widgets.utils.forms_from_types.items import StrFormItem
|
||||
@@ -221,82 +221,36 @@ available_scans_message = AvailableResourceMessage(
|
||||
}
|
||||
)
|
||||
|
||||
scan_history = ScanQueueHistoryMessage(
|
||||
scan_history = ScanHistoryMessage(
|
||||
metadata={},
|
||||
status="COMPLETED",
|
||||
queue_id="94d7cb39-aa70-4060-92de-addcfb64e3c0",
|
||||
info={
|
||||
"queue_id": "94d7cb39-aa70-4060-92de-addcfb64e3c0",
|
||||
"scan_id": ["bc2aa11f-24f6-44d6-8717-95e97fb43015"],
|
||||
"is_scan": [True],
|
||||
"request_blocks": [
|
||||
{
|
||||
"msg": ScanQueueMessage(
|
||||
metadata={
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
"user_metadata": {},
|
||||
"RID": "99321ef7-00ac-4e0c-9120-ce689bd88a4d",
|
||||
},
|
||||
scan_type="line_scan",
|
||||
parameter={
|
||||
"args": {"samx": [0.0, 2.0]},
|
||||
"kwargs": {
|
||||
"steps": 10,
|
||||
"relative": False,
|
||||
"exp_time": 2.0,
|
||||
"burst_at_each_point": 1,
|
||||
"system_config": {"file_suffix": None, "file_directory": None},
|
||||
},
|
||||
},
|
||||
queue="primary",
|
||||
),
|
||||
"RID": "99321ef7-00ac-4e0c-9120-ce689bd88a4d",
|
||||
"scan_motors": ["samx"],
|
||||
"readout_priority": {
|
||||
"monitored": ["samx"],
|
||||
"baseline": [],
|
||||
"on_request": [],
|
||||
"async": [],
|
||||
},
|
||||
"is_scan": True,
|
||||
"scan_number": 176,
|
||||
"scan_id": "bc2aa11f-24f6-44d6-8717-95e97fb43015",
|
||||
"metadata": {
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
"user_metadata": {},
|
||||
"RID": "99321ef7-00ac-4e0c-9120-ce689bd88a4d",
|
||||
},
|
||||
"content": {
|
||||
"scan_type": "line_scan",
|
||||
"parameter": {
|
||||
"args": {"samx": [0.0, 2.0]},
|
||||
"kwargs": {
|
||||
"steps": 10,
|
||||
"relative": False,
|
||||
"exp_time": 2.0,
|
||||
"burst_at_each_point": 1,
|
||||
"system_config": {"file_suffix": None, "file_directory": None},
|
||||
},
|
||||
},
|
||||
"queue": "primary",
|
||||
},
|
||||
"report_instructions": [{"scan_progress": 10}],
|
||||
}
|
||||
],
|
||||
"scan_number": [176],
|
||||
"status": "COMPLETED",
|
||||
"active_request_block": None,
|
||||
scan_id="79cbef20-9ebe-45bb-a44c-f518be27a25c",
|
||||
scan_number=1,
|
||||
dataset_number=1,
|
||||
file_path="/somepath/scan_1.h5",
|
||||
exit_status="closed",
|
||||
start_time=1750618470.936856,
|
||||
end_time=1750618473.668227,
|
||||
scan_name="line_scan",
|
||||
num_points=100,
|
||||
request_inputs={
|
||||
"arg_bundle": ["samx", 0.0, 2.0],
|
||||
"inputs": {},
|
||||
"kwargs": {
|
||||
"steps": 10,
|
||||
"exp_time": 2,
|
||||
"relative": False,
|
||||
"system_config": {"file_suffix": None, "file_directory": None},
|
||||
},
|
||||
},
|
||||
queue="primary",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def scan_control(qtbot, mocked_client): # , mock_dev):
|
||||
mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message)
|
||||
mocked_client.connector.lpush(MessageEndpoints.scan_queue_history(), scan_history)
|
||||
mocked_client.connector.xadd(
|
||||
topic=MessageEndpoints.scan_history(), msg_dict={"data": scan_history}
|
||||
)
|
||||
widget = ScanControl(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user