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

Compare commits

...

10 Commits

36 changed files with 1229 additions and 57 deletions

View File

@@ -144,6 +144,9 @@ tests:
coverage_report:
coverage_format: cobertura
path: coverage.xml
paths:
- tests/reference_failures/
when: always
test-matrix:
parallel:

View File

@@ -289,6 +289,8 @@ class BECConnector(BECWidget):
print("No more connections. Shutting down GUI BEC client.")
self.bec_dispatcher.disconnect_all()
self.client.shutdown()
if hasattr(super(), "cleanup"):
super().cleanup()
# def closeEvent(self, event):
# self.cleanup()

View File

@@ -1,2 +1,8 @@
class BECWidget:
"""Base class for all BEC widgets."""
def closeEvent(self, event):
if hasattr(self, "cleanup"):
self.cleanup()
if hasattr(super(), "closeEvent"):
super().closeEvent(event)

View File

@@ -58,11 +58,12 @@ class DesignerPluginGenerator:
os.path.dirname(os.path.abspath(__file__)), "plugin_templates"
)
def run(self):
def run(self, validate=True):
if self._excluded:
print(f"Plugin {self.widget.__name__} is excluded from generation.")
return
self._check_class_validity()
if validate:
self._check_class_validity()
self._load_templates()
self._write_templates()
@@ -142,7 +143,7 @@ class DesignerPluginGenerator:
if __name__ == "__main__": # pragma: no cover
# from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.dock import BECDockArea
from bec_widgets.widgets.spinner.spinner import SpinnerWidget
generator = DesignerPluginGenerator(BECDockArea)
generator.run()
generator = DesignerPluginGenerator(SpinnerWidget)
generator.run(validate=False)

View File

@@ -0,0 +1,92 @@
import os
import sys
from PIL import Image, ImageChops
from qtpy.QtGui import QPixmap
import bec_widgets
REFERENCE_DIR = os.path.join(
os.path.dirname(os.path.dirname(bec_widgets.__file__)), "tests/references"
)
REFERENCE_DIR_FAILURES = os.path.join(
os.path.dirname(os.path.dirname(bec_widgets.__file__)), "tests/reference_failures"
)
def compare_images(image1_path: str, reference_image_path: str):
"""
Load two images and compare them pixel by pixel
Args:
image1_path(str): The path to the first image
reference_image_path(str): The path to the reference image
Raises:
ValueError: If the images are different
"""
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
os.makedirs(REFERENCE_DIR_FAILURES, exist_ok=True)
image_name = os.path.join(REFERENCE_DIR_FAILURES, os.path.basename(image1_path))
image1.save(image_name)
print(f"Image saved to {image_name}")
raise ValueError("Images are different")
def snap_and_compare(widget: any, output_directory: str, suffix: str = ""):
"""
Save a rendering of a widget and compare it to a reference image
Args:
widget(any): The widget to render
output_directory(str): The directory to save the image to
suffix(str): A suffix to append to the image name
Raises:
ValueError: If the images are different
Examples:
snap_and_compare(widget, tmpdir, suffix="started")
"""
if not isinstance(output_directory, str):
output_directory = str(output_directory)
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 = os.path.join(output_directory, name)
pixmap = QPixmap(widget.size())
widget.render(pixmap)
pixmap.save(test_image_path)
try:
reference_path = os.path.join(REFERENCE_DIR, f"{widget.__class__.__name__}")
reference_image_path = os.path.join(reference_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)
os.makedirs(REFERENCE_DIR_FAILURES, exist_ok=True)
image_name = os.path.join(REFERENCE_DIR_FAILURES, name)
image.save(image_name)
print(f"Image saved to {image_name}")
raise

View File

@@ -27,7 +27,7 @@ class BECQueuePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return ""
return "BEC Core Widgets"
def icon(self):
return QIcon()

View File

@@ -27,7 +27,7 @@ class BECStatusBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return ""
return "BEC Core Widgets"
def icon(self):
return QIcon()

View File

@@ -222,7 +222,7 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
Start ``Backend`` process and render Pyte output as text.
"""
def __init__(self, parent, numColumns, numLines, **kwargs):
def __init__(self, parent, numColumns=125, numLines=50, **kwargs):
super().__init__(parent)
# file descriptor to communicate with the subprocess

View File

@@ -0,0 +1,201 @@
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):
"""A widget that controls a single device."""
ui_file = "device_box.ui"
dimensions = (234, 224)
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, self.ui_file))
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(self.dimensions[0])
db.setFixedWidth(self.dimensions[1])
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_())

View File

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

View 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>

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 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()

View File

@@ -0,0 +1,22 @@
from bec_widgets.widgets.device_box.device_box import DeviceBox
class DeviceControlLine(DeviceBox):
"""A widget that controls a single device."""
ui_file = "device_control_line.ui"
dimensions = (70, 800) # height, width
if __name__ == "__main__": # pragma: no cover
import sys
import qdarktheme
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
qdarktheme.setup_theme("light")
widget = DeviceControlLine(device="samy")
widget.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,181 @@
<?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>785</width>
<height>91</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</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="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="PositionIndicator" name="position_indicator"/>
</item>
<item>
<widget class="QLabel" name="readback">
<property name="minimumSize">
<size>
<width>150</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Position</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="setpoint">
<property name="minimumSize">
<size>
<width>150</width>
<height>24</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QPushButton" name="stop">
<property name="text">
<string>Stop</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="tweak_left">
<property name="minimumSize">
<size>
<width>30</width>
<height>30</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>30</width>
<height>30</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>
<widget class="QDoubleSpinBox" name="step_size"/>
</item>
<item>
<widget class="QToolButton" name="tweak_right">
<property name="minimumSize">
<size>
<width>30</width>
<height>30</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>30</width>
<height>30</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>
<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>
</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>

View 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()

View File

@@ -830,6 +830,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
widget_class=self.__class__.__name__, gui_id=self.gui_id, theme=theme
)
def cleanup(self):
self.clear_all()
super().cleanup()
# def cleanup(self):
# self.clear_all()
# super().cleanup()

View 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_()

View File

@@ -0,0 +1 @@
{'files': ['position_indicator.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 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()

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.position_indicator.position_indicator_plugin import (
PositionIndicatorPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(PositionIndicatorPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

View 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()

View 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())

View File

@@ -0,0 +1 @@
{'files': ['spinner.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 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()

View File

@@ -49,13 +49,6 @@ class VSCodeEditor(WebsiteWidget):
break
self.set_url(self._url)
def closeEvent(self, event):
"""
Hook for the close event to terminate the server.
"""
self.cleanup_vscode()
super().closeEvent(event)
def cleanup_vscode(self):
"""
Cleanup the VSCode editor.
@@ -72,13 +65,6 @@ class VSCodeEditor(WebsiteWidget):
self.cleanup_vscode()
return super().cleanup()
def close(self):
"""
Close the widget.
"""
self.cleanup_vscode()
return super().close()
if __name__ == "__main__": # pragma: no cover
import sys

View File

@@ -69,6 +69,6 @@ if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
mainWin = WebsiteWidget("https://scilog.psi.ch")
mainWin = WebsiteWidget(url="https://scilog.psi.ch")
mainWin.show()
sys.exit(app.exec())

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -49,11 +49,19 @@ class FakePositioner(FakeDevice):
self.read_value = read_value
self.name = name
@property
def precision(self):
return 3
def set_read_value(self, value):
self.read_value = value
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):
self.limits = limits

View 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

View File

@@ -0,0 +1,30 @@
import pytest
import qdarktheme
from bec_widgets.utils.reference_utils import snap_and_compare
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 test_spinner_widget_paint_event(spinner_widget, qtbot):
spinner_widget.paintEvent(None)
def test_spinner_widget_rendered(spinner_widget, qtbot, tmpdir):
spinner_widget.update()
qtbot.wait(200)
snap_and_compare(spinner_widget, str(tmpdir), suffix="")
spinner_widget._started = True
spinner_widget.update()
qtbot.wait(200)
snap_and_compare(spinner_widget, str(tmpdir), suffix="started")

View File

@@ -25,54 +25,49 @@ def test_vscode_widget(qtbot, vscode_widget):
def test_start_server(qtbot, mocked_client):
with mock.patch("bec_widgets.widgets.vscode.vscode.subprocess.Popen") as mock_popen:
mock_process = mock.Mock()
mock_process.stdout.fileno.return_value = 1
mock_process.poll.return_value = None
mock_process.stdout.read.return_value = (
f"available at http://{VSCodeEditor.host}:{VSCodeEditor.port}?tkn={VSCodeEditor.token}"
)
mock_popen.return_value = mock_process
with mock.patch("bec_widgets.widgets.vscode.vscode.select.select") as mock_select:
mock_process = mock.Mock()
mock_process.stdout.fileno.return_value = 1
mock_process.poll.return_value = None
mock_process.stdout.read.return_value = f"available at http://{VSCodeEditor.host}:{VSCodeEditor.port}?tkn={VSCodeEditor.token}"
mock_popen.return_value = mock_process
mock_select.return_value = [[mock_process.stdout], [], []]
widget = VSCodeEditor(client=mocked_client)
widget = VSCodeEditor(client=mocked_client)
mock_popen.assert_called_once_with(
shlex.split(
f"code serve-web --port {widget.port} --connection-token={widget.token} --accept-server-license-terms"
),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
preexec_fn=os.setsid,
)
mock_popen.assert_called_once_with(
shlex.split(
f"code serve-web --port {widget.port} --connection-token={widget.token} --accept-server-license-terms"
),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
preexec_fn=os.setsid,
)
@pytest.fixture
def patched_vscode_process(qtbot, vscode_widget):
with mock.patch("bec_widgets.widgets.vscode.vscode.os.killpg") as mock_killpg:
with mock.patch("bec_widgets.widgets.vscode.vscode.os.getpgid") as mock_getpgid:
with mock.patch(
"bec_widgets.widgets.website.website.WebsiteWidget.closeEvent"
) as mock_close_event:
mock_getpgid.return_value = 123
vscode_widget.process = mock.Mock()
yield vscode_widget, mock_killpg, mock_close_event
mock_getpgid.return_value = 123
vscode_widget.process = mock.Mock()
yield vscode_widget, mock_killpg
def test_close_event(qtbot, patched_vscode_process):
vscode_patched, mock_killpg, mock_close_event = patched_vscode_process
def test_vscode_cleanup(qtbot, patched_vscode_process):
vscode_patched, mock_killpg = patched_vscode_process
vscode_patched.process.pid = 123
vscode_patched.process.poll.return_value = None
vscode_patched.closeEvent(None)
vscode_patched.cleanup()
mock_killpg.assert_called_once_with(123, 15)
vscode_patched.process.wait.assert_called_once()
mock_close_event.assert_called_once()
def test_close_event_on_terminated_code(qtbot, patched_vscode_process):
vscode_patched, mock_killpg, mock_close_event = patched_vscode_process
vscode_patched, mock_killpg = patched_vscode_process
vscode_patched.process.pid = 123
vscode_patched.process.poll.return_value = 0
vscode_patched.closeEvent(None)
vscode_patched.cleanup()
mock_killpg.assert_not_called()
vscode_patched.process.wait.assert_not_called()
mock_close_event.assert_called_once()