mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-14 03:31:50 +02:00
feat(widgets): added device box with spinner
This commit is contained in:
@ -144,6 +144,9 @@ tests:
|
|||||||
coverage_report:
|
coverage_report:
|
||||||
coverage_format: cobertura
|
coverage_format: cobertura
|
||||||
path: coverage.xml
|
path: coverage.xml
|
||||||
|
paths:
|
||||||
|
- tests/unit_tests/reference_failures/
|
||||||
|
when: always
|
||||||
|
|
||||||
test-matrix:
|
test-matrix:
|
||||||
parallel:
|
parallel:
|
||||||
|
0
bec_widgets/widgets/device_box/__init__.py
Normal file
0
bec_widgets/widgets/device_box/__init__.py
Normal file
197
bec_widgets/widgets/device_box/device_box.py
Normal file
197
bec_widgets/widgets/device_box/device_box.py
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from bec_lib.endpoints import MessageEndpoints
|
||||||
|
from bec_lib.messages import ScanQueueMessage
|
||||||
|
from qtpy.QtCore import Property, Signal, Slot
|
||||||
|
from qtpy.QtGui import QDoubleValidator
|
||||||
|
from qtpy.QtWidgets import QDoubleSpinBox, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from bec_widgets.utils import UILoader
|
||||||
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceBox(BECConnector, QWidget):
|
||||||
|
device_changed = Signal(str, str)
|
||||||
|
|
||||||
|
def __init__(self, parent=None, device=None, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
QWidget.__init__(self, parent=parent)
|
||||||
|
self.get_bec_shortcuts()
|
||||||
|
self._device = ""
|
||||||
|
self._limits = None
|
||||||
|
|
||||||
|
self.init_ui()
|
||||||
|
|
||||||
|
if device is not None:
|
||||||
|
self.device = device
|
||||||
|
self.init_device()
|
||||||
|
|
||||||
|
def init_ui(self):
|
||||||
|
self.device_changed.connect(self.on_device_change)
|
||||||
|
|
||||||
|
current_path = os.path.dirname(__file__)
|
||||||
|
self.ui = UILoader(self).loader(os.path.join(current_path, "device_box.ui"))
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
self.layout.addWidget(self.ui)
|
||||||
|
self.layout.setSpacing(0)
|
||||||
|
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
# fix the size of the device box
|
||||||
|
db = self.ui.device_box
|
||||||
|
db.setFixedHeight(234)
|
||||||
|
db.setFixedWidth(224)
|
||||||
|
|
||||||
|
self.ui.step_size.setStepType(QDoubleSpinBox.AdaptiveDecimalStepType)
|
||||||
|
self.ui.stop.clicked.connect(self.on_stop)
|
||||||
|
self.ui.tweak_right.clicked.connect(self.on_tweak_right)
|
||||||
|
self.ui.tweak_right.setToolTip("Tweak right")
|
||||||
|
self.ui.tweak_left.clicked.connect(self.on_tweak_left)
|
||||||
|
self.ui.tweak_left.setToolTip("Tweak left")
|
||||||
|
self.ui.setpoint.returnPressed.connect(self.on_setpoint_change)
|
||||||
|
|
||||||
|
self.setpoint_validator = QDoubleValidator()
|
||||||
|
self.ui.setpoint.setValidator(self.setpoint_validator)
|
||||||
|
self.ui.spinner_widget.start()
|
||||||
|
|
||||||
|
def init_device(self):
|
||||||
|
if self.device in self.dev:
|
||||||
|
data = self.dev[self.device].read()
|
||||||
|
self.on_device_readback({"signals": data}, {})
|
||||||
|
|
||||||
|
@Property(str)
|
||||||
|
def device(self):
|
||||||
|
return self._device
|
||||||
|
|
||||||
|
@device.setter
|
||||||
|
def device(self, value):
|
||||||
|
if not value or not isinstance(value, str):
|
||||||
|
return
|
||||||
|
old_device = self._device
|
||||||
|
self._device = value
|
||||||
|
self.device_changed.emit(old_device, value)
|
||||||
|
|
||||||
|
@Slot(str, str)
|
||||||
|
def on_device_change(self, old_device: str, new_device: str):
|
||||||
|
if new_device not in self.dev:
|
||||||
|
print(f"Device {new_device} not found in the device list")
|
||||||
|
return
|
||||||
|
print(f"Device changed from {old_device} to {new_device}")
|
||||||
|
self.init_device()
|
||||||
|
self.bec_dispatcher.disconnect_slot(
|
||||||
|
self.on_device_readback, MessageEndpoints.device_readback(old_device)
|
||||||
|
)
|
||||||
|
self.bec_dispatcher.connect_slot(
|
||||||
|
self.on_device_readback, MessageEndpoints.device_readback(new_device)
|
||||||
|
)
|
||||||
|
self.ui.device_box.setTitle(new_device)
|
||||||
|
self.ui.readback.setToolTip(f"{self.device} readback")
|
||||||
|
self.ui.setpoint.setToolTip(f"{self.device} setpoint")
|
||||||
|
self.ui.step_size.setToolTip(f"Step size for {new_device}")
|
||||||
|
|
||||||
|
precision = self.dev[new_device].precision
|
||||||
|
if precision is not None:
|
||||||
|
self.ui.step_size.setDecimals(precision)
|
||||||
|
self.ui.step_size.setValue(10**-precision * 10)
|
||||||
|
|
||||||
|
@Slot(dict, dict)
|
||||||
|
def on_device_readback(self, msg_content: dict, metadata: dict):
|
||||||
|
signals = msg_content.get("signals", {})
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
hinted_signals = self.dev[self.device]._hints
|
||||||
|
precision = self.dev[self.device].precision
|
||||||
|
|
||||||
|
readback_val = None
|
||||||
|
setpoint_val = None
|
||||||
|
|
||||||
|
if len(hinted_signals) == 1:
|
||||||
|
signal = hinted_signals[0]
|
||||||
|
readback_val = signals.get(signal, {}).get("value")
|
||||||
|
|
||||||
|
if f"{self.device}_setpoint" in signals:
|
||||||
|
setpoint_val = signals.get(f"{self.device}_setpoint", {}).get("value")
|
||||||
|
|
||||||
|
if f"{self.device}_motor_is_moving" in signals:
|
||||||
|
is_moving = signals.get(f"{self.device}_motor_is_moving", {}).get("value")
|
||||||
|
if is_moving:
|
||||||
|
self.ui.spinner_widget.start()
|
||||||
|
self.ui.spinner_widget.setToolTip("Device is moving")
|
||||||
|
else:
|
||||||
|
self.ui.spinner_widget.stop()
|
||||||
|
self.ui.spinner_widget.setToolTip("Device is idle")
|
||||||
|
|
||||||
|
if readback_val is not None:
|
||||||
|
self.ui.readback.setText(f"{readback_val:.{precision}f}")
|
||||||
|
|
||||||
|
if setpoint_val is not None:
|
||||||
|
self.ui.setpoint.setText(f"{setpoint_val:.{precision}f}")
|
||||||
|
|
||||||
|
limits = self.dev[self.device].limits
|
||||||
|
self.update_limits(limits)
|
||||||
|
if limits is not None and readback_val is not None and limits[0] != limits[1]:
|
||||||
|
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
|
||||||
|
self.ui.position_indicator.on_position_update(pos)
|
||||||
|
|
||||||
|
def update_limits(self, limits):
|
||||||
|
if limits == self._limits:
|
||||||
|
return
|
||||||
|
self._limits = limits
|
||||||
|
if limits is not None and limits[0] != limits[1]:
|
||||||
|
self.ui.position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
|
||||||
|
self.setpoint_validator.setRange(limits[0], limits[1])
|
||||||
|
else:
|
||||||
|
self.ui.position_indicator.setToolTip("No limits set")
|
||||||
|
self.setpoint_validator.setRange(float("-inf"), float("inf"))
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def on_stop(self):
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
params = {
|
||||||
|
"device": self.device,
|
||||||
|
"rpc_id": request_id,
|
||||||
|
"func": "stop",
|
||||||
|
"args": [],
|
||||||
|
"kwargs": {},
|
||||||
|
}
|
||||||
|
msg = ScanQueueMessage(
|
||||||
|
scan_type="device_rpc",
|
||||||
|
parameter=params,
|
||||||
|
queue="emergency",
|
||||||
|
metadata={"RID": request_id, "response": False},
|
||||||
|
)
|
||||||
|
self.client.connector.send(MessageEndpoints.scan_queue_request(), msg)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def step_size(self):
|
||||||
|
return self.ui.step_size.value()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def on_tweak_right(self):
|
||||||
|
self.dev[self.device].move(self.step_size, relative=True)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def on_tweak_left(self):
|
||||||
|
self.dev[self.device].move(-self.step_size, relative=True)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def on_setpoint_change(self):
|
||||||
|
self.ui.setpoint.clearFocus()
|
||||||
|
setpoint = self.ui.setpoint.text()
|
||||||
|
self.dev[self.device].move(float(setpoint), relative=False)
|
||||||
|
self.ui.tweak_left.setToolTip(f"Tweak left by {self.step_size}")
|
||||||
|
self.ui.tweak_right.setToolTip(f"Tweak right by {self.step_size}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import qdarktheme
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
qdarktheme.setup_theme("light")
|
||||||
|
widget = DeviceBox(device="samx")
|
||||||
|
|
||||||
|
widget.show()
|
||||||
|
sys.exit(app.exec_())
|
1
bec_widgets/widgets/device_box/device_box.pyproject
Normal file
1
bec_widgets/widgets/device_box/device_box.pyproject
Normal file
@ -0,0 +1 @@
|
|||||||
|
{'files': ['device_box.py']}
|
179
bec_widgets/widgets/device_box/device_box.ui
Normal file
179
bec_widgets/widgets/device_box/device_box.ui
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
<?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>251</width>
|
||||||
|
<height>289</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>192</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">
|
||||||
|
<item>
|
||||||
|
<widget class="QGroupBox" name="device_box">
|
||||||
|
<property name="title">
|
||||||
|
<string>Device Name</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QGridLayout" name="gridLayout" rowstretch="0,0,0,0,0">
|
||||||
|
<property name="topMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<item row="3" column="1">
|
||||||
|
<widget class="QDoubleSpinBox" name="step_size"/>
|
||||||
|
</item>
|
||||||
|
<item row="3" column="2">
|
||||||
|
<widget class="QToolButton" name="tweak_right">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>50</width>
|
||||||
|
<height>50</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>50</width>
|
||||||
|
<height>50</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>...</string>
|
||||||
|
</property>
|
||||||
|
<property name="iconSize">
|
||||||
|
<size>
|
||||||
|
<width>30</width>
|
||||||
|
<height>30</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="arrowType">
|
||||||
|
<enum>Qt::ArrowType::RightArrow</enum>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="2" column="0" colspan="3">
|
||||||
|
<widget class="QLineEdit" name="setpoint"/>
|
||||||
|
</item>
|
||||||
|
<item row="3" column="0">
|
||||||
|
<widget class="QToolButton" name="tweak_left">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>50</width>
|
||||||
|
<height>50</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>50</width>
|
||||||
|
<height>50</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>...</string>
|
||||||
|
</property>
|
||||||
|
<property name="iconSize">
|
||||||
|
<size>
|
||||||
|
<width>30</width>
|
||||||
|
<height>30</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="arrowType">
|
||||||
|
<enum>Qt::ArrowType::LeftArrow</enum>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="4" column="0" colspan="3">
|
||||||
|
<widget class="QPushButton" name="stop">
|
||||||
|
<property name="text">
|
||||||
|
<string>Stop</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="0" column="0" colspan="3">
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||||
|
<item>
|
||||||
|
<spacer name="horizontalSpacer">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Orientation::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeType">
|
||||||
|
<enum>QSizePolicy::Policy::Expanding</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>40</width>
|
||||||
|
<height>20</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="SpinnerWidget" name="spinner_widget">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>25</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>25</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="PositionIndicator" name="position_indicator"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="readback">
|
||||||
|
<property name="text">
|
||||||
|
<string>Position</string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<customwidgets>
|
||||||
|
<customwidget>
|
||||||
|
<class>SpinnerWidget</class>
|
||||||
|
<extends>QWidget</extends>
|
||||||
|
<header>spinner_widget</header>
|
||||||
|
</customwidget>
|
||||||
|
<customwidget>
|
||||||
|
<class>PositionIndicator</class>
|
||||||
|
<extends>QWidget</extends>
|
||||||
|
<header>position_indicator</header>
|
||||||
|
</customwidget>
|
||||||
|
</customwidgets>
|
||||||
|
<resources/>
|
||||||
|
<connections/>
|
||||||
|
</ui>
|
54
bec_widgets/widgets/device_box/device_box_plugin.py
Normal file
54
bec_widgets/widgets/device_box/device_box_plugin.py
Normal 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 qtpy.QtGui import QIcon
|
||||||
|
|
||||||
|
from bec_widgets.widgets.device_box.device_box import DeviceBox
|
||||||
|
|
||||||
|
DOM_XML = """
|
||||||
|
<ui language='c++'>
|
||||||
|
<widget class='DeviceBox' name='device_box'>
|
||||||
|
</widget>
|
||||||
|
</ui>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._form_editor = None
|
||||||
|
|
||||||
|
def createWidget(self, parent):
|
||||||
|
t = DeviceBox(parent)
|
||||||
|
return t
|
||||||
|
|
||||||
|
def domXml(self):
|
||||||
|
return DOM_XML
|
||||||
|
|
||||||
|
def group(self):
|
||||||
|
return "Device Control"
|
||||||
|
|
||||||
|
def icon(self):
|
||||||
|
return QIcon()
|
||||||
|
|
||||||
|
def includeFile(self):
|
||||||
|
return "device_box"
|
||||||
|
|
||||||
|
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 "DeviceBox"
|
||||||
|
|
||||||
|
def toolTip(self):
|
||||||
|
return "A widget for controlling a single positioner. "
|
||||||
|
|
||||||
|
def whatsThis(self):
|
||||||
|
return self.toolTip()
|
15
bec_widgets/widgets/device_box/register_device_box.py
Normal file
15
bec_widgets/widgets/device_box/register_device_box.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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.device_box.device_box_plugin import DeviceBoxPlugin
|
||||||
|
|
||||||
|
QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceBoxPlugin())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main()
|
71
bec_widgets/widgets/position_indicator/position_indicator.py
Normal file
71
bec_widgets/widgets/position_indicator/position_indicator.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
from qtpy.QtCore import Qt, Slot
|
||||||
|
from qtpy.QtGui import QPainter, QPen
|
||||||
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
|
|
||||||
|
class PositionIndicator(QWidget):
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.position = 0.5
|
||||||
|
self.min_value = 0
|
||||||
|
self.max_value = 100
|
||||||
|
self.scaling_factor = 0.5
|
||||||
|
self.setMinimumHeight(10)
|
||||||
|
|
||||||
|
def set_range(self, min_value, max_value):
|
||||||
|
self.min_value = min_value
|
||||||
|
self.max_value = max_value
|
||||||
|
|
||||||
|
@Slot(float)
|
||||||
|
def on_position_update(self, position: float):
|
||||||
|
self.position = position
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event):
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
width = self.width()
|
||||||
|
height = self.height()
|
||||||
|
|
||||||
|
# Draw horizontal line
|
||||||
|
painter.setPen(Qt.black)
|
||||||
|
painter.drawLine(0, height // 2, width, height // 2)
|
||||||
|
|
||||||
|
# Draw shorter vertical line at the current position
|
||||||
|
x_pos = int(self.position * width)
|
||||||
|
painter.setPen(QPen(Qt.red, 2))
|
||||||
|
short_line_height = int(height * self.scaling_factor)
|
||||||
|
painter.drawLine(
|
||||||
|
x_pos,
|
||||||
|
(height // 2) - (short_line_height // 2),
|
||||||
|
x_pos,
|
||||||
|
(height // 2) + (short_line_height // 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Draw thicker vertical lines at the ends
|
||||||
|
end_line_pen = QPen(Qt.blue, 5)
|
||||||
|
painter.setPen(end_line_pen)
|
||||||
|
painter.drawLine(0, 0, 0, height)
|
||||||
|
painter.drawLine(width - 1, 0, width - 1, height)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from qtpy.QtWidgets import QApplication, QSlider, QVBoxLayout
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
position_indicator = PositionIndicator()
|
||||||
|
slider = QSlider(Qt.Horizontal)
|
||||||
|
slider.valueChanged.connect(lambda value: position_indicator.on_position_update(value / 100))
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.addWidget(position_indicator)
|
||||||
|
layout.addWidget(slider)
|
||||||
|
|
||||||
|
widget = QWidget()
|
||||||
|
widget.setLayout(layout)
|
||||||
|
widget.show()
|
||||||
|
|
||||||
|
app.exec_()
|
@ -0,0 +1 @@
|
|||||||
|
{'files': ['position_indicator.py']}
|
@ -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 qtpy.QtGui import QIcon
|
||||||
|
|
||||||
|
from bec_widgets.widgets.position_indicator.position_indicator import PositionIndicator
|
||||||
|
|
||||||
|
DOM_XML = """
|
||||||
|
<ui language='c++'>
|
||||||
|
<widget class='PositionIndicator' name='position_indicator'>
|
||||||
|
</widget>
|
||||||
|
</ui>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PositionIndicatorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._form_editor = None
|
||||||
|
|
||||||
|
def createWidget(self, parent):
|
||||||
|
t = PositionIndicator(parent)
|
||||||
|
return t
|
||||||
|
|
||||||
|
def domXml(self):
|
||||||
|
return DOM_XML
|
||||||
|
|
||||||
|
def group(self):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def icon(self):
|
||||||
|
return QIcon()
|
||||||
|
|
||||||
|
def includeFile(self):
|
||||||
|
return "position_indicator"
|
||||||
|
|
||||||
|
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 "PositionIndicator"
|
||||||
|
|
||||||
|
def toolTip(self):
|
||||||
|
return "PositionIndicator"
|
||||||
|
|
||||||
|
def whatsThis(self):
|
||||||
|
return self.toolTip()
|
@ -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.position_indicator.position_indicator_plugin import (
|
||||||
|
PositionIndicatorPlugin,
|
||||||
|
)
|
||||||
|
|
||||||
|
QPyDesignerCustomWidgetCollection.addCustomWidget(PositionIndicatorPlugin())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main()
|
0
bec_widgets/widgets/spinner/__init__.py
Normal file
0
bec_widgets/widgets/spinner/__init__.py
Normal file
15
bec_widgets/widgets/spinner/register_spinner_widget.py
Normal file
15
bec_widgets/widgets/spinner/register_spinner_widget.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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.spinner.spinner_widget_plugin import SpinnerWidgetPlugin
|
||||||
|
|
||||||
|
QPyDesignerCustomWidgetCollection.addCustomWidget(SpinnerWidgetPlugin())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main()
|
85
bec_widgets/widgets/spinner/spinner.py
Normal file
85
bec_widgets/widgets/spinner/spinner.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import qdarktheme
|
||||||
|
from qtpy.QtCore import QRect, Qt, QTimer
|
||||||
|
from qtpy.QtGui import QColor, QPainter, QPen
|
||||||
|
from qtpy.QtWidgets import QApplication, QMainWindow, QWidget
|
||||||
|
|
||||||
|
|
||||||
|
def ease_in_out_sine(t):
|
||||||
|
return 1 - np.sin(np.pi * t)
|
||||||
|
|
||||||
|
|
||||||
|
class SpinnerWidget(QWidget):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.angle = 0
|
||||||
|
self.timer = QTimer(self)
|
||||||
|
self.timer.timeout.connect(self.rotate)
|
||||||
|
self.time = 0
|
||||||
|
self.duration = 50
|
||||||
|
self.speed = 50
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._started:
|
||||||
|
return
|
||||||
|
self.timer.start(self.speed)
|
||||||
|
self._started = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if not self._started:
|
||||||
|
return
|
||||||
|
self.timer.stop()
|
||||||
|
self._started = False
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def rotate(self):
|
||||||
|
self.time = (self.time + 1) % self.duration
|
||||||
|
t = self.time / self.duration
|
||||||
|
easing_value = ease_in_out_sine(t)
|
||||||
|
self.angle -= (20 * easing_value) % 360 + 10
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event):
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
size = min(self.width(), self.height())
|
||||||
|
rect = QRect(0, 0, size, size)
|
||||||
|
|
||||||
|
background_color = QColor(200, 200, 200, 50)
|
||||||
|
line_width = 5
|
||||||
|
|
||||||
|
color_palette = qdarktheme.load_palette()
|
||||||
|
|
||||||
|
color = QColor(color_palette.accent().color())
|
||||||
|
|
||||||
|
rect.adjust(line_width, line_width, -line_width, -line_width)
|
||||||
|
|
||||||
|
# Background arc
|
||||||
|
painter.setPen(QPen(background_color, line_width, Qt.SolidLine))
|
||||||
|
adjusted_rect = QRect(rect.left(), rect.top(), rect.width(), rect.height())
|
||||||
|
painter.drawArc(adjusted_rect, 0, 360 * 16)
|
||||||
|
|
||||||
|
if self._started:
|
||||||
|
# Foreground arc
|
||||||
|
pen = QPen(color, line_width, Qt.SolidLine)
|
||||||
|
pen.setCapStyle(Qt.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
proportion = 1 / 4
|
||||||
|
angle_span = int(proportion * 360 * 16)
|
||||||
|
angle_span += angle_span * ease_in_out_sine(self.time / self.duration)
|
||||||
|
painter.drawArc(adjusted_rect, self.angle * 16, int(angle_span))
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
window = QMainWindow()
|
||||||
|
widget = SpinnerWidget()
|
||||||
|
widget.start()
|
||||||
|
window.setCentralWidget(widget)
|
||||||
|
window.show()
|
||||||
|
sys.exit(app.exec())
|
1
bec_widgets/widgets/spinner/spinner_widget.pyproject
Normal file
1
bec_widgets/widgets/spinner/spinner_widget.pyproject
Normal file
@ -0,0 +1 @@
|
|||||||
|
{'files': ['spinner.py']}
|
54
bec_widgets/widgets/spinner/spinner_widget_plugin.py
Normal file
54
bec_widgets/widgets/spinner/spinner_widget_plugin.py
Normal 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 qtpy.QtGui import QIcon
|
||||||
|
|
||||||
|
from bec_widgets.widgets.spinner.spinner import SpinnerWidget
|
||||||
|
|
||||||
|
DOM_XML = """
|
||||||
|
<ui language='c++'>
|
||||||
|
<widget class='SpinnerWidget' name='spinner_widget'>
|
||||||
|
</widget>
|
||||||
|
</ui>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SpinnerWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._form_editor = None
|
||||||
|
|
||||||
|
def createWidget(self, parent):
|
||||||
|
t = SpinnerWidget(parent)
|
||||||
|
return t
|
||||||
|
|
||||||
|
def domXml(self):
|
||||||
|
return DOM_XML
|
||||||
|
|
||||||
|
def group(self):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def icon(self):
|
||||||
|
return QIcon()
|
||||||
|
|
||||||
|
def includeFile(self):
|
||||||
|
return "spinner_widget"
|
||||||
|
|
||||||
|
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 "SpinnerWidget"
|
||||||
|
|
||||||
|
def toolTip(self):
|
||||||
|
return "SpinnerWidget"
|
||||||
|
|
||||||
|
def whatsThis(self):
|
||||||
|
return self.toolTip()
|
@ -49,11 +49,19 @@ class FakePositioner(FakeDevice):
|
|||||||
self.read_value = read_value
|
self.read_value = read_value
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def precision(self):
|
||||||
|
return 3
|
||||||
|
|
||||||
def set_read_value(self, value):
|
def set_read_value(self, value):
|
||||||
self.read_value = value
|
self.read_value = value
|
||||||
|
|
||||||
def read(self):
|
def read(self):
|
||||||
return {self.name: {"value": self.read_value}}
|
return {
|
||||||
|
self.name: {"value": self.read_value},
|
||||||
|
f"{self.name}_setpoint": {"value": self.read_value},
|
||||||
|
f"{self.name}_motor_is_moving": {"value": 0},
|
||||||
|
}
|
||||||
|
|
||||||
def set_limits(self, limits):
|
def set_limits(self, limits):
|
||||||
self.limits = limits
|
self.limits = limits
|
||||||
|
BIN
tests/unit_tests/references/SpinnerWidget_darwin.png
Normal file
BIN
tests/unit_tests/references/SpinnerWidget_darwin.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.3 KiB |
BIN
tests/unit_tests/references/SpinnerWidget_linux.png
Normal file
BIN
tests/unit_tests/references/SpinnerWidget_linux.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.3 KiB |
BIN
tests/unit_tests/references/SpinnerWidget_started_darwin.png
Normal file
BIN
tests/unit_tests/references/SpinnerWidget_started_darwin.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
tests/unit_tests/references/SpinnerWidget_started_linux.png
Normal file
BIN
tests/unit_tests/references/SpinnerWidget_started_linux.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
98
tests/unit_tests/test_device_box.py
Normal file
98
tests/unit_tests/test_device_box.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from bec_lib.endpoints import MessageEndpoints
|
||||||
|
from bec_lib.messages import ScanQueueMessage
|
||||||
|
from qtpy.QtGui import QValidator
|
||||||
|
|
||||||
|
from bec_widgets.widgets.device_box.device_box import DeviceBox
|
||||||
|
|
||||||
|
from .client_mocks import mocked_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def device_box(qtbot, mocked_client):
|
||||||
|
with mock.patch("bec_widgets.widgets.device_box.device_box.uuid.uuid4") as mock_uuid:
|
||||||
|
mock_uuid.return_value = "fake_uuid"
|
||||||
|
db = DeviceBox(device="samx", client=mocked_client)
|
||||||
|
qtbot.addWidget(db)
|
||||||
|
yield db
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_box(device_box):
|
||||||
|
assert device_box.device == "samx"
|
||||||
|
data = device_box.dev["samx"].read()
|
||||||
|
|
||||||
|
setpoint_text = device_box.ui.setpoint.text()
|
||||||
|
# check that the setpoint is taken correctly after init
|
||||||
|
assert float(setpoint_text) == data["samx_setpoint"]["value"]
|
||||||
|
|
||||||
|
# check that the precision is taken correctly after init
|
||||||
|
precision = device_box.dev["samx"].precision
|
||||||
|
assert setpoint_text == f"{data['samx_setpoint']['value']:.{precision}f}"
|
||||||
|
|
||||||
|
# check that the step size is set according to the device precision
|
||||||
|
assert device_box.ui.step_size.value() == 10**-precision * 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_box_update_limits(device_box):
|
||||||
|
device_box._limits = None
|
||||||
|
device_box.update_limits([0, 10])
|
||||||
|
assert device_box._limits == [0, 10]
|
||||||
|
assert device_box.setpoint_validator.bottom() == 0
|
||||||
|
assert device_box.setpoint_validator.top() == 10
|
||||||
|
assert device_box.setpoint_validator.validate("100", 0) == (
|
||||||
|
QValidator.State.Intermediate,
|
||||||
|
"100",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
device_box.update_limits(None)
|
||||||
|
assert device_box._limits is None
|
||||||
|
assert device_box.setpoint_validator.validate("100", 0) == (
|
||||||
|
QValidator.State.Acceptable,
|
||||||
|
"100",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_box_on_stop(device_box):
|
||||||
|
with mock.patch.object(device_box.client.connector, "send") as mock_send:
|
||||||
|
device_box.on_stop()
|
||||||
|
params = {"device": "samx", "rpc_id": "fake_uuid", "func": "stop", "args": [], "kwargs": {}}
|
||||||
|
msg = ScanQueueMessage(
|
||||||
|
scan_type="device_rpc",
|
||||||
|
parameter=params,
|
||||||
|
queue="emergency",
|
||||||
|
metadata={"RID": "fake_uuid", "response": False},
|
||||||
|
)
|
||||||
|
mock_send.assert_called_once_with(MessageEndpoints.scan_queue_request(), msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_box_setpoint_change(device_box):
|
||||||
|
with mock.patch.object(device_box.dev["samx"], "move") as mock_move:
|
||||||
|
device_box.ui.setpoint.setText("100")
|
||||||
|
device_box.on_setpoint_change()
|
||||||
|
mock_move.assert_called_once_with(100, relative=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_box_on_tweak_right(device_box):
|
||||||
|
with mock.patch.object(device_box.dev["samx"], "move") as mock_move:
|
||||||
|
device_box.ui.step_size.setValue(0.1)
|
||||||
|
device_box.on_tweak_right()
|
||||||
|
mock_move.assert_called_once_with(0.1, relative=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_box_on_tweak_left(device_box):
|
||||||
|
with mock.patch.object(device_box.dev["samx"], "move") as mock_move:
|
||||||
|
device_box.ui.step_size.setValue(0.1)
|
||||||
|
device_box.on_tweak_left()
|
||||||
|
mock_move.assert_called_once_with(-0.1, relative=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_box_setpoint_out_of_range(device_box):
|
||||||
|
device_box.update_limits([0, 10])
|
||||||
|
device_box.ui.setpoint.setText("100")
|
||||||
|
device_box.on_setpoint_change()
|
||||||
|
assert device_box.ui.setpoint.text() == "100"
|
||||||
|
assert device_box.ui.setpoint.hasAcceptableInput() == False
|
92
tests/unit_tests/test_spinner.py
Normal file
92
tests/unit_tests/test_spinner.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import qdarktheme
|
||||||
|
from PIL import Image, ImageChops
|
||||||
|
from qtpy.QtGui import QPixmap
|
||||||
|
|
||||||
|
from bec_widgets.widgets.spinner.spinner import SpinnerWidget
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def spinner_widget(qtbot):
|
||||||
|
qdarktheme.setup_theme("light")
|
||||||
|
spinner = SpinnerWidget()
|
||||||
|
qtbot.addWidget(spinner)
|
||||||
|
qtbot.waitExposed(spinner)
|
||||||
|
yield spinner
|
||||||
|
|
||||||
|
|
||||||
|
def save_pixmap(widget, filename):
|
||||||
|
pixmap = QPixmap(widget.size())
|
||||||
|
widget.render(pixmap)
|
||||||
|
pixmap.save(str(filename))
|
||||||
|
return pixmap
|
||||||
|
|
||||||
|
|
||||||
|
def compare_images(image1_path: str, reference_image_path: str):
|
||||||
|
image1 = Image.open(image1_path)
|
||||||
|
image2 = Image.open(reference_image_path)
|
||||||
|
if image1.size != image2.size:
|
||||||
|
raise ValueError("Image size has changed")
|
||||||
|
diff = ImageChops.difference(image1, image2)
|
||||||
|
if diff.getbbox():
|
||||||
|
# copy image1 to the reference directory to upload as artifact
|
||||||
|
output_dir = os.path.join(os.path.dirname(__file__), "reference_failures")
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
image_name = os.path.join(output_dir, os.path.basename(image1_path))
|
||||||
|
image1.save(image_name)
|
||||||
|
print(f"Image saved to {image_name}")
|
||||||
|
|
||||||
|
raise ValueError("Images are different")
|
||||||
|
|
||||||
|
|
||||||
|
def test_spinner_widget_paint_event(spinner_widget, qtbot):
|
||||||
|
spinner_widget.paintEvent(None)
|
||||||
|
|
||||||
|
|
||||||
|
def snap_and_compare(widget, tmpdir, suffix=""):
|
||||||
|
os_suffix = sys.platform
|
||||||
|
|
||||||
|
name = (
|
||||||
|
f"{widget.__class__.__name__}_{suffix}_{os_suffix}.png"
|
||||||
|
if suffix
|
||||||
|
else f"{widget.__class__.__name__}_{os_suffix}.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save the widget to a pixmap
|
||||||
|
test_image_path = str(tmpdir / name)
|
||||||
|
pixmap = QPixmap(widget.size())
|
||||||
|
widget.render(pixmap)
|
||||||
|
pixmap.save(test_image_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
references_path = os.path.join(os.path.dirname(__file__), "references")
|
||||||
|
reference_image_path = os.path.join(references_path, name)
|
||||||
|
|
||||||
|
if not os.path.exists(reference_image_path):
|
||||||
|
raise ValueError(f"Reference image not found: {reference_image_path}")
|
||||||
|
|
||||||
|
compare_images(test_image_path, reference_image_path)
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
image = Image.open(test_image_path)
|
||||||
|
output_dir = os.path.join(os.path.dirname(__file__), "reference_failures")
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
image_name = os.path.join(output_dir, name)
|
||||||
|
image.save(image_name)
|
||||||
|
print(f"Image saved to {image_name}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_spinner_widget_rendered(spinner_widget, qtbot, tmpdir):
|
||||||
|
spinner_widget.update()
|
||||||
|
qtbot.wait(200)
|
||||||
|
snap_and_compare(spinner_widget, tmpdir, suffix="")
|
||||||
|
|
||||||
|
spinner_widget._started = True
|
||||||
|
spinner_widget.update()
|
||||||
|
qtbot.wait(200)
|
||||||
|
|
||||||
|
snap_and_compare(spinner_widget, tmpdir, suffix="started")
|
Reference in New Issue
Block a user