mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-12 03:30:54 +02:00
Compare commits
26 Commits
v1.11.0
...
tomcat/pro
| Author | SHA1 | Date | |
|---|---|---|---|
| f343a692ff | |||
| e1576f41b0 | |||
| 98f86739e3 | |||
| 54e64c9f10 | |||
| 1c8b06cbe6 | |||
| 52c5286d64 | |||
| c405421db9 | |||
| 0ff0c06bd1 | |||
| 955cc64257 | |||
| 09cb08a233 | |||
| 5c83702382 | |||
| 1b0382524f | |||
| 92b802021f | |||
| 48c140f937 | |||
| 42fd78df40 | |||
| 271a4a24e7 | |||
| 1b03ded906 | |||
| bde5618699 | |||
| 6f2eb6b4cd | |||
| 2742a3c6cf | |||
| 809e654087 | |||
| bdb25206d9 | |||
| bd5414288c | |||
| 95f6a7ceb7 | |||
|
|
b75c4c88fe | ||
| e38048964f |
@@ -61,6 +61,7 @@ stages:
|
||||
- pip install -e ./ophyd_devices
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e ./bec/bec_ipython_client
|
||||
- pip install -e ./bec/pytest_bec_e2e
|
||||
|
||||
.install-os-packages: &install-os-packages
|
||||
- apt-get update
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,6 +1,15 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v1.12.0 (2024-12-12)
|
||||
|
||||
### Features
|
||||
|
||||
- **safe_property**: Added decorator to handle errors in Property decorator from qt to not crash
|
||||
designer
|
||||
([`e380489`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e38048964f942f9f4edba225835ad0a937503dd4))
|
||||
|
||||
|
||||
## v1.11.0 (2024-12-11)
|
||||
|
||||
### Features
|
||||
@@ -203,11 +212,3 @@ Depending on the test, auto-updates are enabled or not.
|
||||
|
||||
- **positioner_box**: Adjusted default signals
|
||||
([`8e5c0ad`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8e5c0ad8c8eff5a9308169bc663d2b7230f0ebb1))
|
||||
|
||||
|
||||
## v1.4.0 (2024-11-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **crosshair**: Label of coordinates of TextItem displays numbers in general format
|
||||
([`11e5937`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/11e5937ae0f3c1413acd4e66878a692ebe4ef7d0))
|
||||
|
||||
100
bec_widgets/applications/tomcat_app/tomcat_app.py
Normal file
100
bec_widgets/applications/tomcat_app/tomcat_app.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import os
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import requests
|
||||
import sys
|
||||
|
||||
from PySide6.QtCore import Signal, Slot
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QActionGroup, QIcon
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
|
||||
|
||||
import bec_widgets
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from bec_widgets.examples.general_app.web_links import BECWebLinksMixin
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class TomcatApp(QMainWindow, BECWidget):
|
||||
select_slice = Signal()
|
||||
|
||||
def __init__(self, parent=None, client=None, gui_id=None):
|
||||
super(TomcatApp, self).__init__(parent)
|
||||
BECWidget.__init__(self, client=client, gui_id=gui_id)
|
||||
ui_file_path = os.path.join(os.path.dirname(__file__), "tomcat_app.ui")
|
||||
self.load_ui(ui_file_path)
|
||||
|
||||
self.resize(1280, 720)
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self.bec_dispatcher.connect_slot(self.test_connection, "GPU Fastapi message")
|
||||
self.bec_dispatcher.connect_slot(self.status_update, "GPU Fastapi message")
|
||||
|
||||
self.ui.slider_select.valueChanged.connect(self.select_slice_from_slider)
|
||||
self.proxy_slider = pg.SignalProxy(self.select_slice, rateLimit=2, slot=self.send_slice)
|
||||
|
||||
self.image_widget = self.ui.image_widget
|
||||
|
||||
def load_ui(self, ui_file):
|
||||
loader = UILoader(self)
|
||||
self.ui = loader.loader(ui_file)
|
||||
self.setCentralWidget(self.ui)
|
||||
|
||||
def status_update(self, msg):
|
||||
status = msg["data"]["GPU SVC Status"]
|
||||
|
||||
if status == "Running":
|
||||
self.ui.radio_io.setChecked(True)
|
||||
else:
|
||||
self.ui.radio_io.setChecked(False)
|
||||
|
||||
# @SafeSlot(dict, dict)
|
||||
def test_connection(self, msg):
|
||||
print("Test Connection")
|
||||
print(msg)
|
||||
# print(metadata)
|
||||
|
||||
def select_slice_from_slider(self, value):
|
||||
print(value)
|
||||
self.select_slice.emit()
|
||||
|
||||
@Slot()
|
||||
def send_slice(self):
|
||||
value = self.ui.slider_select.value()
|
||||
requests.post(
|
||||
"http://ra-gpu-006:8000/api/v1/reco/single_slice",
|
||||
json={"slice": value, "rot_center": 0},
|
||||
)
|
||||
print(f"Sending slice {value}")
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-General-App.png"), size=QSize(48, 48)
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
config = ServiceConfig(redis={"host": "ra-gpu-006", "port": 6379})
|
||||
|
||||
client = BECClient(config=config, connector_cls=QtRedisConnector)
|
||||
|
||||
main_window = TomcatApp(client=client)
|
||||
main_window.show()
|
||||
main_window.image_widget.image("RecoPreview")
|
||||
# custom_data = np.random.rand(100, 100)
|
||||
# main_window.image_widget._image.add_custom_image("custom", custom_data)
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
278
bec_widgets/applications/tomcat_app/tomcat_app.ui
Normal file
278
bec_widgets/applications/tomcat_app/tomcat_app.ui
Normal file
@@ -0,0 +1,278 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>800</width>
|
||||
<height>631</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="BECImageWidget" name="image_widget"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>800</width>
|
||||
<height>29</height>
|
||||
</rect>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<widget class="QDockWidget" name="status_dock">
|
||||
<property name="windowTitle">
|
||||
<string>Status</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>1</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents">
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="text">
|
||||
<string>Dataset loaded</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QRadioButton" name="radio_dataset">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_8">
|
||||
<property name="text">
|
||||
<string>I/O Service</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QRadioButton" name="radio_io">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QLineEdit" name="lineEdit_2"/>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_10">
|
||||
<property name="text">
|
||||
<string>Reco Service</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QRadioButton" name="radioButton_4">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QLabel" name="label_11">
|
||||
<property name="text">
|
||||
<string>reco-adress</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="label_9">
|
||||
<property name="text">
|
||||
<string>Streaming Service</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QRadioButton" name="radioButton_3">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QLabel" name="label_12">
|
||||
<property name="text">
|
||||
<string>stream-adress</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<widget class="QDockWidget" name="dock_data">
|
||||
<property name="windowTitle">
|
||||
<string>Dataset</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>1</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_2">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Input Dataset (full HDF5 path)</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="line_edit_dataset"/>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="button_load">
|
||||
<property name="text">
|
||||
<string>Load Dataset</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="button_close">
|
||||
<property name="text">
|
||||
<string>Close Dataset</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Single Slice Live Preview</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ToggleSwitch" name="toggle_preview"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>Select Slice</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSlider" name="slider_select">
|
||||
<property name="maximum">
|
||||
<number>500</number>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_4">
|
||||
<property name="text">
|
||||
<string>Current Index</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="text">
|
||||
<string>Rotation center (offset from center)</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSlider" name="slider_rotation">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>Rotation Index</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<widget class="QPushButton" name="button_reconstruct">
|
||||
<property name="text">
|
||||
<string>Reconstruct</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="button_single">
|
||||
<property name="text">
|
||||
<string>Single Slice</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="button_async">
|
||||
<property name="text">
|
||||
<string>Sample Async task</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>BECImageWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_image_widget</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>toggle_switch</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>slider_select</sender>
|
||||
<signal>valueChanged(int)</signal>
|
||||
<receiver>label_4</receiver>
|
||||
<slot>setNum(int)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>61</x>
|
||||
<y>396</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>73</x>
|
||||
<y>425</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
@@ -27,25 +27,17 @@ class AutoUpdates:
|
||||
|
||||
def __init__(self, gui: BECDockArea):
|
||||
self.gui = gui
|
||||
self.msg_queue = Queue()
|
||||
self.auto_update_thread = None
|
||||
self._shutdown_sentinel = object()
|
||||
self.start()
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Start the auto update thread.
|
||||
"""
|
||||
self.auto_update_thread = threading.Thread(target=self.process_queue)
|
||||
self.auto_update_thread.start()
|
||||
self._default_dock = None
|
||||
self._default_fig = None
|
||||
|
||||
def start_default_dock(self):
|
||||
"""
|
||||
Create a default dock for the auto updates.
|
||||
"""
|
||||
dock = self.gui.add_dock("default_figure")
|
||||
dock.add_widget("BECFigure")
|
||||
self.dock_name = "default_figure"
|
||||
self._default_dock = self.gui.add_dock(self.dock_name)
|
||||
self._default_dock.add_widget("BECFigure")
|
||||
self._default_fig = self._default_dock.widget_list[0]
|
||||
|
||||
@staticmethod
|
||||
def get_scan_info(msg) -> ScanInfo:
|
||||
@@ -73,15 +65,9 @@ class AutoUpdates:
|
||||
"""
|
||||
Get the default figure from the GUI.
|
||||
"""
|
||||
dock = self.gui.panels.get(self.dock_name, [])
|
||||
if not dock:
|
||||
return None
|
||||
widgets = dock.widget_list
|
||||
if not widgets:
|
||||
return None
|
||||
return widgets[0]
|
||||
return self._default_fig
|
||||
|
||||
def run(self, msg):
|
||||
def do_update(self, msg):
|
||||
"""
|
||||
Run the update function if enabled.
|
||||
"""
|
||||
@@ -90,20 +76,9 @@ class AutoUpdates:
|
||||
if msg.status != "open":
|
||||
return
|
||||
info = self.get_scan_info(msg)
|
||||
self.handler(info)
|
||||
return self.handler(info)
|
||||
|
||||
def process_queue(self):
|
||||
"""
|
||||
Process the message queue.
|
||||
"""
|
||||
while True:
|
||||
msg = self.msg_queue.get()
|
||||
if msg is self._shutdown_sentinel:
|
||||
break
|
||||
self.run(msg)
|
||||
|
||||
@staticmethod
|
||||
def get_selected_device(monitored_devices, selected_device):
|
||||
def get_selected_device(self, monitored_devices, selected_device):
|
||||
"""
|
||||
Get the selected device for the plot. If no device is selected, the first
|
||||
device in the monitored devices list is selected.
|
||||
@@ -120,14 +95,11 @@ class AutoUpdates:
|
||||
Default update function.
|
||||
"""
|
||||
if info.scan_name == "line_scan" and info.scan_report_devices:
|
||||
self.simple_line_scan(info)
|
||||
return
|
||||
return self.simple_line_scan(info)
|
||||
if info.scan_name == "grid_scan" and info.scan_report_devices:
|
||||
self.simple_grid_scan(info)
|
||||
return
|
||||
return self.simple_grid_scan(info)
|
||||
if info.scan_report_devices:
|
||||
self.best_effort(info)
|
||||
return
|
||||
return self.best_effort(info)
|
||||
|
||||
def simple_line_scan(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
@@ -137,12 +109,19 @@ class AutoUpdates:
|
||||
if not fig:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0]
|
||||
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
|
||||
selected_device = yield self.gui.selected_device
|
||||
dev_y = self.get_selected_device(info.monitored_devices, selected_device)
|
||||
if not dev_y:
|
||||
return
|
||||
fig.clear_all()
|
||||
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
|
||||
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
|
||||
yield fig.clear_all()
|
||||
yield fig.plot(
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
label=f"Scan {info.scan_number} - {dev_y}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
y_label=dev_y,
|
||||
)
|
||||
|
||||
def simple_grid_scan(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
@@ -153,12 +132,18 @@ class AutoUpdates:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0]
|
||||
dev_y = info.scan_report_devices[1]
|
||||
dev_z = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
|
||||
fig.clear_all()
|
||||
plt = fig.plot(
|
||||
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
|
||||
selected_device = yield self.gui.selected_device
|
||||
dev_z = self.get_selected_device(info.monitored_devices, selected_device)
|
||||
yield fig.clear_all()
|
||||
yield fig.plot(
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
z_name=dev_z,
|
||||
label=f"Scan {info.scan_number} - {dev_z}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
y_label=dev_y,
|
||||
)
|
||||
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
|
||||
|
||||
def best_effort(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
@@ -168,17 +153,16 @@ class AutoUpdates:
|
||||
if not fig:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0]
|
||||
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
|
||||
selected_device = yield self.gui.selected_device
|
||||
dev_y = self.get_selected_device(info.monitored_devices, selected_device)
|
||||
if not dev_y:
|
||||
return
|
||||
fig.clear_all()
|
||||
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
|
||||
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
Shutdown the auto update thread.
|
||||
"""
|
||||
self.msg_queue.put(self._shutdown_sentinel)
|
||||
if self.auto_update_thread:
|
||||
self.auto_update_thread.join()
|
||||
yield fig.clear_all()
|
||||
yield fig.plot(
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
label=f"Scan {info.scan_number} - {dev_y}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
y_label=dev_y,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import enum
|
||||
from typing import Literal, Optional, overload
|
||||
|
||||
from bec_widgets.cli.client_utils import BECGuiClientMixin, RPCBase, rpc_call
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
|
||||
|
||||
# pylint: skip-file
|
||||
|
||||
@@ -342,7 +342,7 @@ class BECDock(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class BECDockArea(RPCBase, BECGuiClientMixin):
|
||||
class BECDockArea(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def _config_dict(self) -> "dict":
|
||||
@@ -353,6 +353,13 @@ class BECDockArea(RPCBase, BECGuiClientMixin):
|
||||
dict: The configuration of the widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def selected_device(self) -> "str":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def panels(self) -> "dict[str, BECDock]":
|
||||
@@ -480,6 +487,12 @@ class BECDockArea(RPCBase, BECGuiClientMixin):
|
||||
Hide all windows including floating docks.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def delete(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
|
||||
class BECFigure(RPCBase):
|
||||
@property
|
||||
|
||||
@@ -7,61 +7,33 @@ import os
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.auto_updates import AutoUpdates
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib import messages
|
||||
from bec_lib.connector import MessageObject
|
||||
from bec_lib.device import DeviceBase
|
||||
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
# from bec_lib.connector import MessageObject
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
else:
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
# from bec_lib.connector import MessageObject
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def rpc_call(func):
|
||||
"""
|
||||
A decorator for calling a function on the server.
|
||||
|
||||
Args:
|
||||
func: The function to call.
|
||||
|
||||
Returns:
|
||||
The result of the function call.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
# we could rely on a strict type check here, but this is more flexible
|
||||
# moreover, it would anyway crash for objects...
|
||||
out = []
|
||||
for arg in args:
|
||||
if hasattr(arg, "name"):
|
||||
arg = arg.name
|
||||
out.append(arg)
|
||||
args = tuple(out)
|
||||
for key, val in kwargs.items():
|
||||
if hasattr(val, "name"):
|
||||
kwargs[key] = val.name
|
||||
if not self.gui_is_alive():
|
||||
raise RuntimeError("GUI is not alive")
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _get_output(process, logger) -> None:
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
|
||||
stream_buffer = {process.stdout: [], process.stderr: []}
|
||||
@@ -132,29 +104,79 @@ class RepeatTimer(threading.Timer):
|
||||
self.function(*self.args, **self.kwargs)
|
||||
|
||||
|
||||
class BECGuiClientMixin:
|
||||
@contextmanager
|
||||
def wait_for_server(client):
|
||||
timeout = client._startup_timeout
|
||||
if not timeout:
|
||||
if client.gui_is_alive():
|
||||
# there is hope, let's wait a bit
|
||||
timeout = 1
|
||||
else:
|
||||
raise RuntimeError("GUI is not alive")
|
||||
try:
|
||||
if client._gui_started_event.wait(timeout=timeout):
|
||||
client._gui_started_timer.cancel()
|
||||
client._gui_started_timer.join()
|
||||
else:
|
||||
raise TimeoutError("Could not connect to GUI server")
|
||||
finally:
|
||||
# after initial waiting period, do not wait so much any more
|
||||
# (only relevant if GUI didn't start)
|
||||
client._startup_timeout = 0
|
||||
yield
|
||||
|
||||
|
||||
### ----------------------------
|
||||
### NOTE
|
||||
### it is far easier to extend the 'delete' method on the client side,
|
||||
### to know when the client is deleted, rather than listening to server
|
||||
### to get notified. However, 'generate_cli.py' cannot add extra stuff
|
||||
### in the generated client module. So, here a class with the same name
|
||||
### is created, and client module is patched.
|
||||
class BECDockArea(client.BECDockArea):
|
||||
def delete(self):
|
||||
if self is BECGuiClient._top_level["main"].widget:
|
||||
raise RuntimeError("Cannot delete main window")
|
||||
super().delete()
|
||||
try:
|
||||
del BECGuiClient._top_level[self._gui_id]
|
||||
except KeyError:
|
||||
# if a dock area is not at top level
|
||||
pass
|
||||
|
||||
|
||||
client.BECDockArea = BECDockArea
|
||||
### ----------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class WidgetDesc:
|
||||
title: str
|
||||
widget: BECDockArea
|
||||
|
||||
|
||||
class BECGuiClient(RPCBase):
|
||||
_top_level = {}
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._auto_updates_enabled = True
|
||||
self._auto_updates = None
|
||||
self._startup_timeout = 0
|
||||
self._gui_started_timer = None
|
||||
self._gui_started_event = threading.Event()
|
||||
self._process = None
|
||||
self._process_output_processing_thread = None
|
||||
self._target_endpoint = MessageEndpoints.scan_status()
|
||||
self._selected_device = None
|
||||
|
||||
@property
|
||||
def windows(self):
|
||||
return self._top_level
|
||||
|
||||
@property
|
||||
def auto_updates(self):
|
||||
if self._auto_updates_enabled:
|
||||
self._gui_started_event.wait()
|
||||
return self._auto_updates
|
||||
|
||||
def shutdown_auto_updates(self):
|
||||
if self._auto_updates_enabled:
|
||||
if self._auto_updates is not None:
|
||||
self._auto_updates.shutdown()
|
||||
self._auto_updates = None
|
||||
with wait_for_server(self):
|
||||
return self._auto_updates
|
||||
|
||||
def _get_update_script(self) -> AutoUpdates | None:
|
||||
eps = imd.entry_points(group="bec.widgets.auto_updates")
|
||||
@@ -175,49 +197,59 @@ class BECGuiClientMixin:
|
||||
"""
|
||||
Selected device for the plot.
|
||||
"""
|
||||
return self._selected_device
|
||||
auto_update_config_ep = MessageEndpoints.gui_auto_update_config(self._gui_id)
|
||||
auto_update_config = self._client.connector.get(auto_update_config_ep)
|
||||
if auto_update_config:
|
||||
return auto_update_config.selected_device
|
||||
return None
|
||||
|
||||
@selected_device.setter
|
||||
def selected_device(self, device: str | DeviceBase):
|
||||
if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
|
||||
self._selected_device = device.name
|
||||
self._client.connector.set_and_publish(
|
||||
MessageEndpoints.gui_auto_update_config(self._gui_id),
|
||||
messages.GUIAutoUpdateConfigMessage(selected_device=device.name),
|
||||
)
|
||||
elif isinstance(device, str):
|
||||
self._selected_device = device
|
||||
self._client.connector.set_and_publish(
|
||||
MessageEndpoints.gui_auto_update_config(self._gui_id),
|
||||
messages.GUIAutoUpdateConfigMessage(selected_device=device),
|
||||
)
|
||||
else:
|
||||
raise ValueError("Device must be a string or a device object")
|
||||
|
||||
def _start_update_script(self) -> None:
|
||||
self._client.connector.register(
|
||||
self._target_endpoint, cb=self._handle_msg_update, parent=self
|
||||
)
|
||||
self._client.connector.register(MessageEndpoints.scan_status(), cb=self._handle_msg_update)
|
||||
|
||||
@staticmethod
|
||||
def _handle_msg_update(msg: MessageObject, parent: BECGuiClientMixin) -> None:
|
||||
if parent.auto_updates is not None:
|
||||
def _handle_msg_update(self, msg: MessageObject) -> None:
|
||||
if self.auto_updates is not None:
|
||||
# pylint: disable=protected-access
|
||||
parent._update_script_msg_parser(msg.value)
|
||||
return self._update_script_msg_parser(msg.value)
|
||||
|
||||
def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
|
||||
if isinstance(msg, messages.ScanStatusMessage):
|
||||
if not self.gui_is_alive():
|
||||
return
|
||||
if self._auto_updates_enabled:
|
||||
self.auto_updates.msg_queue.put(msg)
|
||||
return self.auto_updates.do_update(msg)
|
||||
|
||||
def _gui_post_startup(self):
|
||||
self._top_level["main"] = WidgetDesc(
|
||||
title="BEC Widgets", widget=BECDockArea(gui_id=self._gui_id)
|
||||
)
|
||||
if self._auto_updates_enabled:
|
||||
if self._auto_updates is None:
|
||||
auto_updates = self._get_update_script()
|
||||
if auto_updates is None:
|
||||
AutoUpdates.create_default_dock = True
|
||||
AutoUpdates.enabled = True
|
||||
auto_updates = AutoUpdates(gui=self)
|
||||
auto_updates = AutoUpdates(self._top_level["main"].widget)
|
||||
if auto_updates.create_default_dock:
|
||||
auto_updates.start_default_dock()
|
||||
# fig = auto_updates.get_default_figure()
|
||||
self._start_update_script()
|
||||
self._auto_updates = auto_updates
|
||||
self._do_show_all()
|
||||
self._gui_started_event.set()
|
||||
self.show_all()
|
||||
|
||||
def start_server(self, wait=False) -> None:
|
||||
"""
|
||||
@@ -225,8 +257,8 @@ class BECGuiClientMixin:
|
||||
"""
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
logger.success("GUI starting...")
|
||||
self._startup_timeout = 5
|
||||
self._gui_started_event.clear()
|
||||
self._start_update_script()
|
||||
self._process, self._process_output_processing_thread = _start_plot_process(
|
||||
self._gui_id, self.__class__, self._client._service_config.config, logger=logger
|
||||
)
|
||||
@@ -239,27 +271,66 @@ class BECGuiClientMixin:
|
||||
threading.current_thread().cancel()
|
||||
|
||||
self._gui_started_timer = RepeatTimer(
|
||||
1, lambda: self.gui_is_alive() and gui_started_callback(self._gui_post_startup)
|
||||
0.5, lambda: self.gui_is_alive() and gui_started_callback(self._gui_post_startup)
|
||||
)
|
||||
self._gui_started_timer.start()
|
||||
|
||||
if wait:
|
||||
self._gui_started_event.wait()
|
||||
|
||||
def show_all(self):
|
||||
self._gui_started_event.wait()
|
||||
def _dump(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
return rpc_client._run_rpc("_dump")
|
||||
|
||||
def start(self):
|
||||
return self.start_server()
|
||||
|
||||
def _do_show_all(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
rpc_client._run_rpc("show")
|
||||
for window in self._top_level.values():
|
||||
window.widget.show()
|
||||
|
||||
def show_all(self):
|
||||
with wait_for_server(self):
|
||||
return self._do_show_all()
|
||||
|
||||
def hide_all(self):
|
||||
self._gui_started_event.wait()
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
rpc_client._run_rpc("hide")
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
rpc_client._run_rpc("hide")
|
||||
for window in self._top_level.values():
|
||||
window.widget.hide()
|
||||
|
||||
def show(self):
|
||||
if self._process is not None:
|
||||
return self.show_all()
|
||||
# backward compatibility: show() was also starting server
|
||||
return self.start_server(wait=True)
|
||||
|
||||
def hide(self):
|
||||
return self.hide_all()
|
||||
|
||||
@property
|
||||
def main(self):
|
||||
"""Return client to main dock area (in main window)"""
|
||||
with wait_for_server(self):
|
||||
return self._top_level["main"].widget
|
||||
|
||||
def new(self, title):
|
||||
"""Ask main window to create a new top-level dock area"""
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
widget = rpc_client._run_rpc("new_dock_area", title)
|
||||
self._top_level[widget._gui_id] = WidgetDesc(title=title, widget=widget)
|
||||
return widget
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close the gui window.
|
||||
"""
|
||||
self._top_level.clear()
|
||||
|
||||
if self._gui_started_timer is not None:
|
||||
self._gui_started_timer.cancel()
|
||||
self._gui_started_timer.join()
|
||||
@@ -274,130 +345,3 @@ class BECGuiClientMixin:
|
||||
self._process_output_processing_thread.join()
|
||||
self._process.wait()
|
||||
self._process = None
|
||||
self.shutdown_auto_updates()
|
||||
|
||||
|
||||
class RPCResponseTimeoutError(Exception):
|
||||
"""Exception raised when an RPC response is not received within the expected time."""
|
||||
|
||||
def __init__(self, request_id, timeout):
|
||||
super().__init__(
|
||||
f"RPC response not received within {timeout} seconds for request ID {request_id}"
|
||||
)
|
||||
|
||||
|
||||
class RPCBase:
|
||||
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
|
||||
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
|
||||
self._config = config if config is not None else {}
|
||||
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
|
||||
self._parent = parent
|
||||
self._msg_wait_event = threading.Event()
|
||||
self._rpc_response = None
|
||||
super().__init__()
|
||||
# print(f"RPCBase: {self._gui_id}")
|
||||
|
||||
def __repr__(self):
|
||||
type_ = type(self)
|
||||
qualname = type_.__qualname__
|
||||
return f"<{qualname} object at {hex(id(self))}>"
|
||||
|
||||
@property
|
||||
def _root(self):
|
||||
"""
|
||||
Get the root widget. This is the BECFigure widget that holds
|
||||
the anchor gui_id.
|
||||
"""
|
||||
parent = self
|
||||
# pylint: disable=protected-access
|
||||
while parent._parent is not None:
|
||||
parent = parent._parent
|
||||
return parent
|
||||
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
Args:
|
||||
method: The method to call.
|
||||
args: The arguments to pass to the method.
|
||||
wait_for_rpc_response: Whether to wait for the RPC response.
|
||||
kwargs: The keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
rpc_msg = messages.GUIInstructionMessage(
|
||||
action=method,
|
||||
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
|
||||
metadata={"request_id": request_id},
|
||||
)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
receiver = self._root._gui_id
|
||||
if wait_for_rpc_response:
|
||||
self._rpc_response = None
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
cb=self._on_rpc_response,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
if wait_for_rpc_response:
|
||||
try:
|
||||
finished = self._msg_wait_event.wait(10)
|
||||
if not finished:
|
||||
raise RPCResponseTimeoutError(request_id, timeout)
|
||||
finally:
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
||||
)
|
||||
# get class name
|
||||
if not self._rpc_response.accepted:
|
||||
raise ValueError(self._rpc_response.message["error"])
|
||||
msg_result = self._rpc_response.message.get("result")
|
||||
self._rpc_response = None
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
|
||||
@staticmethod
|
||||
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
|
||||
msg = msg.value
|
||||
parent._msg_wait_event.set()
|
||||
parent._rpc_response = msg
|
||||
|
||||
def _create_widget_from_msg_result(self, msg_result):
|
||||
if msg_result is None:
|
||||
return None
|
||||
if isinstance(msg_result, list):
|
||||
return [self._create_widget_from_msg_result(res) for res in msg_result]
|
||||
if isinstance(msg_result, dict):
|
||||
if "__rpc__" not in msg_result:
|
||||
return {
|
||||
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
|
||||
}
|
||||
cls = msg_result.pop("widget_class", None)
|
||||
msg_result.pop("__rpc__", None)
|
||||
|
||||
if not cls:
|
||||
return msg_result
|
||||
|
||||
cls = getattr(client, cls)
|
||||
# print(msg_result)
|
||||
return cls(parent=self, **msg_result)
|
||||
return msg_result
|
||||
|
||||
def gui_is_alive(self):
|
||||
"""
|
||||
Check if the GUI is alive.
|
||||
"""
|
||||
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
|
||||
if heart is None:
|
||||
return False
|
||||
if heart.status == messages.BECStatus.RUNNING:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -35,7 +35,7 @@ from __future__ import annotations
|
||||
import enum
|
||||
from typing import Literal, Optional, overload
|
||||
|
||||
from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
|
||||
|
||||
# pylint: skip-file"""
|
||||
|
||||
@@ -84,7 +84,7 @@ class Widgets(str, enum.Enum):
|
||||
# Generate the content
|
||||
if cls.__name__ == "BECDockArea":
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase, BECGuiClientMixin):"""
|
||||
class {class_name}(RPCBase):"""
|
||||
else:
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
|
||||
0
bec_widgets/cli/rpc/__init__.py
Normal file
0
bec_widgets/cli/rpc/__init__.py
Normal file
177
bec_widgets/cli/rpc/rpc_base.py
Normal file
177
bec_widgets/cli/rpc/rpc_base.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib import messages
|
||||
from bec_lib.connector import MessageObject
|
||||
else:
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
# from bec_lib.connector import MessageObject
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
|
||||
|
||||
def rpc_call(func):
|
||||
"""
|
||||
A decorator for calling a function on the server.
|
||||
|
||||
Args:
|
||||
func: The function to call.
|
||||
|
||||
Returns:
|
||||
The result of the function call.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
# we could rely on a strict type check here, but this is more flexible
|
||||
# moreover, it would anyway crash for objects...
|
||||
out = []
|
||||
for arg in args:
|
||||
if hasattr(arg, "name"):
|
||||
arg = arg.name
|
||||
out.append(arg)
|
||||
args = tuple(out)
|
||||
for key, val in kwargs.items():
|
||||
if hasattr(val, "name"):
|
||||
kwargs[key] = val.name
|
||||
if not self.gui_is_alive():
|
||||
raise RuntimeError("GUI is not alive")
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RPCResponseTimeoutError(Exception):
|
||||
"""Exception raised when an RPC response is not received within the expected time."""
|
||||
|
||||
def __init__(self, request_id, timeout):
|
||||
super().__init__(
|
||||
f"RPC response not received within {timeout} seconds for request ID {request_id}"
|
||||
)
|
||||
|
||||
|
||||
class RPCBase:
|
||||
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
|
||||
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
|
||||
self._config = config if config is not None else {}
|
||||
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
|
||||
self._parent = parent
|
||||
self._msg_wait_event = threading.Event()
|
||||
self._rpc_response = None
|
||||
super().__init__()
|
||||
# print(f"RPCBase: {self._gui_id}")
|
||||
|
||||
def __repr__(self):
|
||||
type_ = type(self)
|
||||
qualname = type_.__qualname__
|
||||
return f"<{qualname} object at {hex(id(self))}>"
|
||||
|
||||
@property
|
||||
def _root(self):
|
||||
"""
|
||||
Get the root widget. This is the BECFigure widget that holds
|
||||
the anchor gui_id.
|
||||
"""
|
||||
parent = self
|
||||
# pylint: disable=protected-access
|
||||
while parent._parent is not None:
|
||||
parent = parent._parent
|
||||
return parent
|
||||
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
Args:
|
||||
method: The method to call.
|
||||
args: The arguments to pass to the method.
|
||||
wait_for_rpc_response: Whether to wait for the RPC response.
|
||||
kwargs: The keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
rpc_msg = messages.GUIInstructionMessage(
|
||||
action=method,
|
||||
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
|
||||
metadata={"request_id": request_id},
|
||||
)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
receiver = self._root._gui_id
|
||||
if wait_for_rpc_response:
|
||||
self._rpc_response = None
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
cb=self._on_rpc_response,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
if wait_for_rpc_response:
|
||||
try:
|
||||
finished = self._msg_wait_event.wait(timeout)
|
||||
if not finished:
|
||||
raise RPCResponseTimeoutError(request_id, timeout)
|
||||
finally:
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
||||
)
|
||||
# get class name
|
||||
if not self._rpc_response.accepted:
|
||||
raise ValueError(self._rpc_response.message["error"])
|
||||
msg_result = self._rpc_response.message.get("result")
|
||||
self._rpc_response = None
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
|
||||
@staticmethod
|
||||
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
|
||||
msg = msg.value
|
||||
parent._msg_wait_event.set()
|
||||
parent._rpc_response = msg
|
||||
|
||||
def _create_widget_from_msg_result(self, msg_result):
|
||||
if msg_result is None:
|
||||
return None
|
||||
if isinstance(msg_result, list):
|
||||
return [self._create_widget_from_msg_result(res) for res in msg_result]
|
||||
if isinstance(msg_result, dict):
|
||||
if "__rpc__" not in msg_result:
|
||||
return {
|
||||
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
|
||||
}
|
||||
cls = msg_result.pop("widget_class", None)
|
||||
msg_result.pop("__rpc__", None)
|
||||
|
||||
if not cls:
|
||||
return msg_result
|
||||
|
||||
cls = getattr(client, cls)
|
||||
# print(msg_result)
|
||||
return cls(parent=self, **msg_result)
|
||||
return msg_result
|
||||
|
||||
def gui_is_alive(self):
|
||||
"""
|
||||
Check if the GUI is alive.
|
||||
"""
|
||||
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
|
||||
if heart is None:
|
||||
return False
|
||||
if heart.status == messages.BECStatus.RUNNING:
|
||||
return True
|
||||
return False
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
import types
|
||||
from contextlib import contextmanager, redirect_stderr, redirect_stdout
|
||||
from typing import Union
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
@@ -12,7 +14,8 @@ from bec_lib.service_config import ServiceConfig
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
|
||||
from bec_widgets.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
@@ -23,6 +26,27 @@ messages = lazy_import("bec_lib.messages")
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@contextmanager
|
||||
def rpc_exception_hook(err_func):
|
||||
"""This context replaces the popup message box for error display with a specific hook"""
|
||||
# get error popup utility singleton
|
||||
popup = ErrorPopupUtility()
|
||||
# save current setting
|
||||
old_exception_hook = popup.custom_exception_hook
|
||||
|
||||
# install err_func, if it is a callable
|
||||
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
|
||||
err_func({"error": popup.get_error_message(exc_type, value, tb)})
|
||||
|
||||
popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup)
|
||||
|
||||
try:
|
||||
yield popup
|
||||
finally:
|
||||
# restore state of error popup utility singleton
|
||||
popup.custom_exception_hook = old_exception_hook
|
||||
|
||||
|
||||
class BECWidgetsCLIServer:
|
||||
|
||||
def __init__(
|
||||
@@ -57,18 +81,19 @@ class BECWidgetsCLIServer:
|
||||
def on_rpc_update(self, msg: dict, metadata: dict):
|
||||
request_id = metadata.get("request_id")
|
||||
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
|
||||
try:
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error while executing RPC instruction: {e}")
|
||||
self.send_response(request_id, False, {"error": str(e)})
|
||||
else:
|
||||
logger.debug(f"RPC instruction executed successfully: {res}")
|
||||
self.send_response(request_id, True, {"result": res})
|
||||
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
|
||||
try:
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error while executing RPC instruction: {e}")
|
||||
self.send_response(request_id, False, {"error": str(e)})
|
||||
else:
|
||||
logger.debug(f"RPC instruction executed successfully: {res}")
|
||||
self.send_response(request_id, True, {"result": res})
|
||||
|
||||
def send_response(self, request_id: str, accepted: bool, msg: dict):
|
||||
self.client.connector.set_and_publish(
|
||||
@@ -181,14 +206,8 @@ def main():
|
||||
|
||||
import bec_widgets
|
||||
|
||||
bec_logger.level = bec_logger.LOGLEVEL.DEBUG
|
||||
if __name__ != "__main__":
|
||||
# if not running as main, set the log level to critical
|
||||
# pylint: disable=protected-access
|
||||
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.CRITICAL
|
||||
|
||||
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
|
||||
parser.add_argument("--id", type=str, help="The id of the server")
|
||||
parser.add_argument("--id", type=str, default="test", help="The id of the server")
|
||||
parser.add_argument(
|
||||
"--gui_class",
|
||||
type=str,
|
||||
@@ -199,10 +218,20 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.gui_class == "BECFigure":
|
||||
gui_class = BECFigure
|
||||
elif args.gui_class == "BECDockArea":
|
||||
if args.hide:
|
||||
# if we start hidden, it means we are under control of the client
|
||||
# -> set the log level to critical to not see all the messages
|
||||
# pylint: disable=protected-access
|
||||
# bec_logger._stderr_log_level = bec_logger.LOGLEVEL.CRITICAL
|
||||
bec_logger.level = bec_logger.LOGLEVEL.CRITICAL
|
||||
else:
|
||||
# verbose log
|
||||
bec_logger.level = bec_logger.LOGLEVEL.DEBUG
|
||||
|
||||
if args.gui_class == "BECDockArea":
|
||||
gui_class = BECDockArea
|
||||
elif args.gui_class == "BECFigure":
|
||||
gui_class = BECFigure
|
||||
else:
|
||||
print(
|
||||
"Please specify a valid gui_class to run. Use -h for help."
|
||||
@@ -213,8 +242,10 @@ def main():
|
||||
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
|
||||
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
|
||||
app = QApplication(sys.argv)
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
app.setApplicationName("BEC Figure")
|
||||
# set close on last window, only if not under control of client ;
|
||||
# indeed, Qt considers a hidden window a closed window, so if all windows
|
||||
# are hidden by default it exits
|
||||
app.setQuitOnLastWindowClosed(not args.hide)
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
@@ -222,6 +253,8 @@ def main():
|
||||
size=QSize(48, 48),
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
# store gui id within QApplication object, to make it available to all widgets
|
||||
app.gui_id = args.id
|
||||
|
||||
server = _start_server(args.id, gui_class, args.config)
|
||||
|
||||
@@ -233,7 +266,6 @@ def main():
|
||||
|
||||
gui = server.gui
|
||||
win.setCentralWidget(gui)
|
||||
win.resize(800, 600)
|
||||
if not args.hide:
|
||||
win.show()
|
||||
|
||||
@@ -242,6 +274,12 @@ def main():
|
||||
def sigint_handler(*args):
|
||||
# display message, for people to let it terminate gracefully
|
||||
print("Caught SIGINT, exiting")
|
||||
# first hide all top level windows
|
||||
# this is to discriminate the cases between "user clicks on [X]"
|
||||
# (which should be filtered, to not close -see BECDockArea-)
|
||||
# or "app is asked to close"
|
||||
for window in app.topLevelWidgets():
|
||||
window.hide() # so, we know we can exit because it is hidden
|
||||
app.quit()
|
||||
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
@@ -250,6 +288,5 @@ def main():
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.argv = ["bec_widgets.cli.server", "--id", "e2860", "--gui_class", "BECDockArea"]
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -2,10 +2,46 @@ import functools
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from qtpy.QtCore import QObject, Qt, Signal, Slot
|
||||
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
|
||||
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, **prop_kwargs):
|
||||
"""
|
||||
Decorator to create a Qt Property with a safe setter that won't crash Designer on errors.
|
||||
Behaves similarly to SafeSlot, but for properties.
|
||||
|
||||
Args:
|
||||
prop_type: The property type (e.g., str, bool, "QStringList", etc.)
|
||||
popup_error (bool): If True, show popup on error, otherwise just handle it silently.
|
||||
*prop_args, **prop_kwargs: Additional arguments and keyword arguments accepted by Property.
|
||||
"""
|
||||
|
||||
def decorator(getter):
|
||||
class PropertyWrapper:
|
||||
def __init__(self, getter_func):
|
||||
self.getter_func = getter_func
|
||||
|
||||
def setter(self, setter_func):
|
||||
@functools.wraps(setter_func)
|
||||
def safe_setter(self_, value):
|
||||
try:
|
||||
return setter_func(self_, value)
|
||||
except Exception:
|
||||
if popup_error:
|
||||
ErrorPopupUtility().custom_exception_hook(
|
||||
*sys.exc_info(), popup_error=True
|
||||
)
|
||||
else:
|
||||
return
|
||||
|
||||
return Property(prop_type, self.getter_func, safe_setter, *prop_args, **prop_kwargs)
|
||||
|
||||
return PropertyWrapper(getter)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
|
||||
to the passed function, to display errors instead of potentially raising an exception
|
||||
@@ -91,6 +127,12 @@ class _ErrorPopupUtility(QObject):
|
||||
msg.setMinimumHeight(400)
|
||||
msg.exec_()
|
||||
|
||||
def show_property_error(self, title, message, widget):
|
||||
"""
|
||||
Show a property-specific error message.
|
||||
"""
|
||||
self.error_occurred.emit(title, message, widget)
|
||||
|
||||
def format_traceback(self, traceback_message: str) -> str:
|
||||
"""
|
||||
Format the traceback message to be displayed in the error popup by adding indentation to each line.
|
||||
@@ -127,12 +169,14 @@ class _ErrorPopupUtility(QObject):
|
||||
error_message = " ".join(captured_message)
|
||||
return error_message
|
||||
|
||||
def get_error_message(self, exctype, value, tb):
|
||||
return "".join(traceback.format_exception(exctype, value, tb))
|
||||
|
||||
def custom_exception_hook(self, exctype, value, tb, popup_error=False):
|
||||
if popup_error or self.enable_error_popup:
|
||||
error_message = traceback.format_exception(exctype, value, tb)
|
||||
self.error_occurred.emit(
|
||||
"Method error" if popup_error else "Application Error",
|
||||
"".join(error_message),
|
||||
self.get_error_message(exctype, value, tb),
|
||||
self.parent(),
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -12,7 +12,7 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Literal
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -28,6 +27,15 @@ class WidgetHandler(ABC):
|
||||
def set_value(self, widget: QWidget, value):
|
||||
"""Set a value on the widget instance."""
|
||||
|
||||
def connect_change_signal(self, widget: QWidget, slot):
|
||||
"""
|
||||
Connect a change signal from this widget to the given slot.
|
||||
If the widget type doesn't have a known "value changed" signal, do nothing.
|
||||
|
||||
slot: a function accepting two arguments (widget, value)
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class LineEditHandler(WidgetHandler):
|
||||
"""Handler for QLineEdit widgets."""
|
||||
@@ -38,6 +46,9 @@ class LineEditHandler(WidgetHandler):
|
||||
def set_value(self, widget: QLineEdit, value: str) -> None:
|
||||
widget.setText(value)
|
||||
|
||||
def connect_change_signal(self, widget: QLineEdit, slot):
|
||||
widget.textChanged.connect(lambda text, w=widget: slot(w, text))
|
||||
|
||||
|
||||
class ComboBoxHandler(WidgetHandler):
|
||||
"""Handler for QComboBox widgets."""
|
||||
@@ -53,6 +64,11 @@ class ComboBoxHandler(WidgetHandler):
|
||||
if isinstance(value, int):
|
||||
widget.setCurrentIndex(value)
|
||||
|
||||
def connect_change_signal(self, widget: QComboBox, slot):
|
||||
# currentIndexChanged(int) or currentIndexChanged(str) both possible.
|
||||
# We use currentIndexChanged(int) for a consistent behavior.
|
||||
widget.currentIndexChanged.connect(lambda idx, w=widget: slot(w, self.get_value(w)))
|
||||
|
||||
|
||||
class TableWidgetHandler(WidgetHandler):
|
||||
"""Handler for QTableWidget widgets."""
|
||||
@@ -72,6 +88,16 @@ class TableWidgetHandler(WidgetHandler):
|
||||
item = QTableWidgetItem(str(cell_value))
|
||||
widget.setItem(row, col, item)
|
||||
|
||||
def connect_change_signal(self, widget: QTableWidget, slot):
|
||||
# If desired, we could connect cellChanged(row, col) and then fetch all data.
|
||||
# This might be noisy if table is large.
|
||||
# For demonstration, connect cellChanged to update entire table value.
|
||||
def on_cell_changed(row, col, w=widget):
|
||||
val = self.get_value(w)
|
||||
slot(w, val)
|
||||
|
||||
widget.cellChanged.connect(on_cell_changed)
|
||||
|
||||
|
||||
class SpinBoxHandler(WidgetHandler):
|
||||
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
|
||||
@@ -82,6 +108,9 @@ class SpinBoxHandler(WidgetHandler):
|
||||
def set_value(self, widget, value):
|
||||
widget.setValue(value)
|
||||
|
||||
def connect_change_signal(self, widget: QSpinBox | QDoubleSpinBox, slot):
|
||||
widget.valueChanged.connect(lambda val, w=widget: slot(w, val))
|
||||
|
||||
|
||||
class CheckBoxHandler(WidgetHandler):
|
||||
"""Handler for QCheckBox widgets."""
|
||||
@@ -92,6 +121,9 @@ class CheckBoxHandler(WidgetHandler):
|
||||
def set_value(self, widget, value):
|
||||
widget.setChecked(value)
|
||||
|
||||
def connect_change_signal(self, widget: QCheckBox, slot):
|
||||
widget.toggled.connect(lambda val, w=widget: slot(w, val))
|
||||
|
||||
|
||||
class LabelHandler(WidgetHandler):
|
||||
"""Handler for QLabel widgets."""
|
||||
@@ -99,12 +131,15 @@ class LabelHandler(WidgetHandler):
|
||||
def get_value(self, widget, **kwargs):
|
||||
return widget.text()
|
||||
|
||||
def set_value(self, widget, value):
|
||||
def set_value(self, widget: QLabel, value):
|
||||
widget.setText(value)
|
||||
|
||||
# QLabel typically doesn't have user-editable changes. No signal to connect.
|
||||
# If needed, this can remain empty.
|
||||
|
||||
|
||||
class WidgetIO:
|
||||
"""Public interface for getting and setting values using handler mapping"""
|
||||
"""Public interface for getting, setting values and connecting signals using handler mapping"""
|
||||
|
||||
_handlers = {
|
||||
QLineEdit: LineEditHandler,
|
||||
@@ -148,6 +183,17 @@ class WidgetIO:
|
||||
elif not ignore_errors:
|
||||
raise ValueError(f"No handler for widget type: {type(widget)}")
|
||||
|
||||
@staticmethod
|
||||
def connect_widget_change_signal(widget, slot):
|
||||
"""
|
||||
Connect the widget's value-changed signal to a generic slot function (widget, value).
|
||||
This now delegates the logic to the widget's handler.
|
||||
"""
|
||||
handler_class = WidgetIO._find_handler(widget)
|
||||
if handler_class:
|
||||
handler = handler_class()
|
||||
handler.connect_change_signal(widget, slot)
|
||||
|
||||
@staticmethod
|
||||
def check_and_adjust_limits(spin_box: QDoubleSpinBox, number: float):
|
||||
"""
|
||||
@@ -309,8 +355,8 @@ class WidgetHierarchy:
|
||||
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
|
||||
|
||||
|
||||
# Example application to demonstrate the usage of the functions
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# Example usage
|
||||
def hierarchy_example(): # pragma: no cover
|
||||
app = QApplication([])
|
||||
|
||||
# Create instance of WidgetHierarchy
|
||||
@@ -365,3 +411,37 @@ if __name__ == "__main__": # pragma: no cover
|
||||
print(f"Config dict new REDUCED: {config_dict_new_reduced}")
|
||||
|
||||
app.exec()
|
||||
|
||||
|
||||
def widget_io_signal_example(): # pragma: no cover
|
||||
app = QApplication([])
|
||||
|
||||
main_widget = QWidget()
|
||||
layout = QVBoxLayout(main_widget)
|
||||
line_edit = QLineEdit(main_widget)
|
||||
combo_box = QComboBox(main_widget)
|
||||
spin_box = QSpinBox(main_widget)
|
||||
combo_box.addItems(["Option 1", "Option 2", "Option 3"])
|
||||
|
||||
layout.addWidget(line_edit)
|
||||
layout.addWidget(combo_box)
|
||||
layout.addWidget(spin_box)
|
||||
|
||||
main_widget.show()
|
||||
|
||||
def universal_slot(w, val):
|
||||
print(f"Widget {w.objectName() or w} changed, new value: {val}")
|
||||
|
||||
# Connect all supported widgets through their handlers
|
||||
WidgetIO.connect_widget_change_signal(line_edit, universal_slot)
|
||||
WidgetIO.connect_widget_change_signal(combo_box, universal_slot)
|
||||
WidgetIO.connect_widget_change_signal(spin_box, universal_slot)
|
||||
|
||||
app.exec_()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# Change example function to test different scenarios
|
||||
|
||||
# hierarchy_example()
|
||||
widget_io_signal_example()
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic import Field
|
||||
from pyqtgraph.dockarea import Dock, DockLabel
|
||||
from qtpy import QtCore, QtGui
|
||||
|
||||
from bec_widgets.cli.rpc_wigdet_handler import widget_handler
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ from __future__ import annotations
|
||||
from typing import Literal, Optional
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import Field
|
||||
from pyqtgraph.dockarea.DockArea import DockArea
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QPainter, QPaintEvent
|
||||
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QApplication, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.toolbar import (
|
||||
@@ -43,6 +44,7 @@ class BECDockArea(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
USER_ACCESS = [
|
||||
"_config_dict",
|
||||
"selected_device",
|
||||
"panels",
|
||||
"save_state",
|
||||
"remove_dock",
|
||||
@@ -55,6 +57,7 @@ class BECDockArea(BECWidget, QWidget):
|
||||
"temp_areas",
|
||||
"show",
|
||||
"hide",
|
||||
"delete",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
@@ -158,6 +161,9 @@ class BECDockArea(BECWidget, QWidget):
|
||||
self.toolbar.addWidget(DarkModeButton(toolbar=True))
|
||||
self._hook_toolbar()
|
||||
|
||||
def minimumSizeHint(self):
|
||||
return QSize(800, 600)
|
||||
|
||||
def _hook_toolbar(self):
|
||||
# Menu Plot
|
||||
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
|
||||
@@ -210,6 +216,17 @@ class BECDockArea(BECWidget, QWidget):
|
||||
"Add docks using 'add_dock' method from CLI\n or \n Add widget docks using the toolbar",
|
||||
)
|
||||
|
||||
@property
|
||||
def selected_device(self) -> str:
|
||||
gui_id = QApplication.instance().gui_id
|
||||
auto_update_config = self.client.connector.get(
|
||||
MessageEndpoints.gui_auto_update_config(gui_id)
|
||||
)
|
||||
try:
|
||||
return auto_update_config.selected_device
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def panels(self) -> dict[str, BECDock]:
|
||||
"""
|
||||
@@ -406,6 +423,17 @@ class BECDockArea(BECWidget, QWidget):
|
||||
self.dock_area.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.parent() is None:
|
||||
# we are at top-level (independent window)
|
||||
if self.isVisible():
|
||||
# we are visible => user clicked on [X]
|
||||
# (when closeEvent is called from shutdown procedure,
|
||||
# everything is hidden first)
|
||||
# so, let's ignore "close", and do hide instead
|
||||
event.ignore()
|
||||
self.setVisible(False)
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close the dock area and cleanup.
|
||||
@@ -418,14 +446,24 @@ class BECDockArea(BECWidget, QWidget):
|
||||
"""Show all windows including floating docks."""
|
||||
super().show()
|
||||
for docks in self.panels.values():
|
||||
if docks.window() is self:
|
||||
# avoid recursion
|
||||
continue
|
||||
docks.window().show()
|
||||
|
||||
def hide(self):
|
||||
"""Hide all windows including floating docks."""
|
||||
super().hide()
|
||||
for docks in self.panels.values():
|
||||
if docks.window() is self:
|
||||
# avoid recursion
|
||||
continue
|
||||
docks.window().hide()
|
||||
|
||||
def delete(self):
|
||||
self.hide()
|
||||
self.deleteLater()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
@@ -634,7 +634,7 @@ class BECImageShow(BECPlotBase):
|
||||
)
|
||||
image_item.connected = False
|
||||
if monitor and image_item.connected is False:
|
||||
self.entry_validator.validate_monitor(monitor)
|
||||
# self.entry_validator.validate_monitor(monitor)
|
||||
if self.image_type == "device_monitor_1d":
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_1d(monitor)
|
||||
|
||||
@@ -20,7 +20,7 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
from typeguard import typechecked
|
||||
|
||||
from bec_widgets.cli.rpc_wigdet_handler import widget_handler
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
|
||||
|
||||
class LayoutManagerWidget(QWidget):
|
||||
|
||||
@@ -1,9 +1,41 @@
|
||||
from qtpy.QtWidgets import QMainWindow
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
|
||||
class BECMainWindow(QMainWindow, BECConnector):
|
||||
def __init__(self, *args, **kwargs):
|
||||
BECConnector.__init__(self, **kwargs)
|
||||
QMainWindow.__init__(self, *args, **kwargs)
|
||||
|
||||
def _dump(self):
|
||||
"""Return a dictionary with informations about the application state, for use in tests"""
|
||||
# TODO: ModularToolBar and something else leak top-level widgets (3 or 4 QMenu + 2 QWidget);
|
||||
# so, a filtering based on title is applied here, but the solution is to not have those widgets
|
||||
# as top-level (so for now, a window with no title does not appear in _dump() result)
|
||||
|
||||
# NOTE: the main window itself is excluded, since we want to dump dock areas
|
||||
info = {
|
||||
tlw.gui_id: {
|
||||
"title": tlw.windowTitle(),
|
||||
"visible": tlw.isVisible(),
|
||||
"class": str(type(tlw)),
|
||||
}
|
||||
for tlw in QApplication.instance().topLevelWidgets()
|
||||
if tlw is not self and tlw.windowTitle()
|
||||
}
|
||||
# Add the main window dock area
|
||||
info[self.centralWidget().gui_id] = {
|
||||
"title": self.windowTitle(),
|
||||
"visible": self.isVisible(),
|
||||
"class": str(type(self.centralWidget())),
|
||||
}
|
||||
return info
|
||||
|
||||
def new_dock_area(self, name):
|
||||
dock_area = BECDockArea()
|
||||
dock_area.resize(dock_area.minimumSizeHint())
|
||||
dock_area.window().setWindowTitle(name)
|
||||
dock_area.show()
|
||||
return dock_area
|
||||
|
||||
@@ -190,7 +190,7 @@ class BECImageWidget(BECWidget, QWidget):
|
||||
###################################
|
||||
# User Access Methods from image
|
||||
###################################
|
||||
@SafeSlot(popup_error=True)
|
||||
@SafeSlot(popup_error=False)
|
||||
def image(
|
||||
self,
|
||||
monitor: str,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "1.11.0"
|
||||
version = "1.12.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -30,7 +30,7 @@ dependencies = [
|
||||
dev = [
|
||||
"coverage~=7.0",
|
||||
"fakeredis~=2.23, >=2.23.2",
|
||||
"pytest-bec-e2e~=2.16",
|
||||
"pytest-bec-e2e>=2.21.4, <=4.0",
|
||||
"pytest-qt~=4.4",
|
||||
"pytest-random-order~=1.1",
|
||||
"pytest-timeout~=2.2",
|
||||
|
||||
@@ -5,8 +5,7 @@ from contextlib import contextmanager
|
||||
import pytest
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
|
||||
from bec_widgets.cli.client import BECDockArea
|
||||
from bec_widgets.cli.client_utils import _start_plot_process
|
||||
from bec_widgets.cli.client_utils import BECGuiClient, _start_plot_process
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.widgets.containers.figure import BECFigure
|
||||
|
||||
@@ -41,27 +40,37 @@ def plot_server(gui_id, klass, client_lib):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rpc_server_figure(gui_id, bec_client_lib):
|
||||
def connected_client_figure(gui_id, bec_client_lib):
|
||||
with plot_server(gui_id, BECFigure, bec_client_lib) as server:
|
||||
yield server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rpc_server_dock(gui_id, bec_client_lib):
|
||||
dock_area = BECDockArea(gui_id=gui_id)
|
||||
dock_area._auto_updates_enabled = False
|
||||
def connected_client_gui_obj(gui_id, bec_client_lib):
|
||||
gui = BECGuiClient(gui_id=gui_id)
|
||||
try:
|
||||
dock_area.start_server(wait=True)
|
||||
yield dock_area
|
||||
gui.start_server(wait=True)
|
||||
yield gui
|
||||
finally:
|
||||
dock_area.close()
|
||||
gui.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rpc_server_dock_w_auto_updates(gui_id, bec_client_lib):
|
||||
dock_area = BECDockArea(gui_id=gui_id)
|
||||
def connected_client_dock(gui_id, bec_client_lib):
|
||||
gui = BECGuiClient(gui_id=gui_id)
|
||||
gui._auto_updates_enabled = False
|
||||
try:
|
||||
dock_area.start_server(wait=True)
|
||||
yield dock_area
|
||||
gui.start_server(wait=True)
|
||||
yield gui.main
|
||||
finally:
|
||||
dock_area.close()
|
||||
gui.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connected_client_dock_w_auto_updates(gui_id, bec_client_lib):
|
||||
gui = BECGuiClient(gui_id=gui_id)
|
||||
try:
|
||||
gui.start_server(wait=True)
|
||||
yield gui, gui.main
|
||||
finally:
|
||||
gui.close()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
|
||||
from bec_widgets.cli.auto_updates import AutoUpdates
|
||||
from bec_widgets.cli.client import BECDockArea, BECFigure, BECImageShow, BECMotorMap, BECWaveform
|
||||
from bec_widgets.utils import Colors
|
||||
|
||||
@@ -12,9 +12,9 @@ from bec_widgets.utils import Colors
|
||||
# pylint: disable=too-many-locals
|
||||
|
||||
|
||||
def test_rpc_add_dock_with_figure_e2e(bec_client_lib, rpc_server_dock):
|
||||
def test_rpc_add_dock_with_figure_e2e(bec_client_lib, connected_client_dock):
|
||||
# BEC client shortcuts
|
||||
dock = rpc_server_dock
|
||||
dock = connected_client_dock
|
||||
client = bec_client_lib
|
||||
dev = client.device_manager.devices
|
||||
scans = client.scans
|
||||
@@ -123,8 +123,8 @@ def test_rpc_add_dock_with_figure_e2e(bec_client_lib, rpc_server_dock):
|
||||
)
|
||||
|
||||
|
||||
def test_dock_manipulations_e2e(rpc_server_dock):
|
||||
dock = rpc_server_dock
|
||||
def test_dock_manipulations_e2e(connected_client_dock):
|
||||
dock = connected_client_dock
|
||||
|
||||
d0 = dock.add_dock("dock_0")
|
||||
d1 = dock.add_dock("dock_1")
|
||||
@@ -155,8 +155,8 @@ def test_dock_manipulations_e2e(rpc_server_dock):
|
||||
assert len(dock.temp_areas) == 0
|
||||
|
||||
|
||||
def test_ring_bar(rpc_server_dock):
|
||||
dock = rpc_server_dock
|
||||
def test_ring_bar(connected_client_dock):
|
||||
dock = connected_client_dock
|
||||
|
||||
d0 = dock.add_dock(name="dock_0")
|
||||
|
||||
@@ -182,8 +182,8 @@ def test_ring_bar(rpc_server_dock):
|
||||
assert bar_colors == expected_colors_light or bar_colors == expected_colors_dark
|
||||
|
||||
|
||||
def test_ring_bar_scan_update(bec_client_lib, rpc_server_dock):
|
||||
dock = rpc_server_dock
|
||||
def test_ring_bar_scan_update(bec_client_lib, connected_client_dock):
|
||||
dock = connected_client_dock
|
||||
|
||||
d0 = dock.add_dock("dock_0")
|
||||
|
||||
@@ -234,19 +234,20 @@ def test_ring_bar_scan_update(bec_client_lib, rpc_server_dock):
|
||||
assert bar_config["rings"][1]["max_value"] == final_samy
|
||||
|
||||
|
||||
def test_auto_update(bec_client_lib, rpc_server_dock_w_auto_updates, qtbot):
|
||||
def test_auto_update(bec_client_lib, connected_client_dock_w_auto_updates, qtbot):
|
||||
client = bec_client_lib
|
||||
dev = client.device_manager.devices
|
||||
scans = client.scans
|
||||
queue = client.queue
|
||||
dock = rpc_server_dock_w_auto_updates
|
||||
gui, dock = connected_client_dock_w_auto_updates
|
||||
auto_updates = gui.auto_updates
|
||||
|
||||
def get_default_figure():
|
||||
return dock.auto_updates.get_default_figure()
|
||||
return auto_updates.get_default_figure()
|
||||
|
||||
plt = get_default_figure()
|
||||
|
||||
dock.selected_device = "bpm4i"
|
||||
gui.selected_device = "bpm4i"
|
||||
|
||||
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
|
||||
status.wait()
|
||||
@@ -274,7 +275,7 @@ def test_auto_update(bec_client_lib, rpc_server_dock_w_auto_updates, qtbot):
|
||||
)
|
||||
status.wait()
|
||||
|
||||
plt = dock.auto_updates.get_default_figure()
|
||||
plt = auto_updates.get_default_figure()
|
||||
widgets = plt.widget_list
|
||||
qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
|
||||
plt_data = widgets[0].get_all_data()
|
||||
@@ -291,3 +292,69 @@ def test_auto_update(bec_client_lib, rpc_server_dock_w_auto_updates, qtbot):
|
||||
plt_data[f"Scan {status.scan.scan_number} - {dock.selected_device}"]["y"]
|
||||
== last_scan_data["samy"]["samy"].val
|
||||
)
|
||||
|
||||
|
||||
def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
|
||||
gui = connected_client_gui_obj
|
||||
|
||||
assert gui.selected_device is None
|
||||
assert len(gui.windows) == 1
|
||||
assert gui.windows["main"].widget is gui.main
|
||||
assert gui.windows["main"].title == "BEC Widgets"
|
||||
mw = gui.main
|
||||
assert mw.__class__.__name__ == "BECDockArea"
|
||||
|
||||
xw = gui.new("X")
|
||||
assert xw.__class__.__name__ == "BECDockArea"
|
||||
assert len(gui.windows) == 2
|
||||
|
||||
gui_info = gui._dump()
|
||||
mw_info = gui_info[mw._gui_id]
|
||||
assert mw_info["title"] == "BEC Widgets"
|
||||
assert mw_info["visible"]
|
||||
xw_info = gui_info[xw._gui_id]
|
||||
assert xw_info["title"] == "X"
|
||||
assert xw_info["visible"]
|
||||
|
||||
gui.hide()
|
||||
gui_info = gui._dump()
|
||||
assert not any(windows["visible"] for windows in gui_info.values())
|
||||
|
||||
gui.show()
|
||||
gui_info = gui._dump()
|
||||
assert all(windows["visible"] for windows in gui_info.values())
|
||||
|
||||
assert gui.gui_is_alive()
|
||||
gui.close()
|
||||
assert not gui.gui_is_alive()
|
||||
gui.start_server(wait=True)
|
||||
assert gui.gui_is_alive()
|
||||
# calling start multiple times should not change anything
|
||||
gui.start_server(wait=True)
|
||||
gui.start()
|
||||
# gui.windows should have main, and main dock area should have same gui_id as before
|
||||
assert len(gui.windows) == 1
|
||||
assert gui.windows["main"].widget._gui_id == mw._gui_id
|
||||
# communication should work, main dock area should have same id and be visible
|
||||
gui_info = gui._dump()
|
||||
assert gui_info[mw._gui_id]["visible"]
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
gui.main.delete()
|
||||
|
||||
yw = gui.new("Y")
|
||||
assert len(gui.windows) == 2
|
||||
yw.delete()
|
||||
assert len(gui.windows) == 1
|
||||
# check it is really deleted on server
|
||||
gui_info = gui._dump()
|
||||
assert yw._gui_id not in gui_info
|
||||
|
||||
|
||||
def test_rpc_call_with_exception_in_safeslot_error_popup(connected_client_gui_obj, qtbot):
|
||||
gui = connected_client_gui_obj
|
||||
|
||||
gui.main.add_dock("test")
|
||||
with pytest.raises(ValueError):
|
||||
gui.main.add_dock("test")
|
||||
# time.sleep(0.1)
|
||||
|
||||
@@ -7,8 +7,8 @@ from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
|
||||
|
||||
|
||||
def test_rpc_waveform1d_custom_curve(rpc_server_figure):
|
||||
fig = BECFigure(rpc_server_figure)
|
||||
def test_rpc_waveform1d_custom_curve(connected_client_figure):
|
||||
fig = BECFigure(connected_client_figure)
|
||||
|
||||
ax = fig.plot()
|
||||
curve = ax.plot(x=[1, 2, 3], y=[1, 2, 3])
|
||||
@@ -20,8 +20,8 @@ def test_rpc_waveform1d_custom_curve(rpc_server_figure):
|
||||
assert len(fig.widgets[ax._rpc_id].curves) == 1
|
||||
|
||||
|
||||
def test_rpc_plotting_shortcuts_init_configs(rpc_server_figure, qtbot):
|
||||
fig = BECFigure(rpc_server_figure)
|
||||
def test_rpc_plotting_shortcuts_init_configs(connected_client_figure, qtbot):
|
||||
fig = BECFigure(connected_client_figure)
|
||||
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i")
|
||||
im = fig.image("eiger")
|
||||
@@ -78,8 +78,8 @@ def test_rpc_plotting_shortcuts_init_configs(rpc_server_figure, qtbot):
|
||||
}
|
||||
|
||||
|
||||
def test_rpc_waveform_scan(rpc_server_figure, bec_client_lib):
|
||||
fig = BECFigure(rpc_server_figure)
|
||||
def test_rpc_waveform_scan(connected_client_figure, bec_client_lib):
|
||||
fig = BECFigure(connected_client_figure)
|
||||
|
||||
# add 3 different curves to track
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i")
|
||||
@@ -109,8 +109,8 @@ def test_rpc_waveform_scan(rpc_server_figure, bec_client_lib):
|
||||
assert plt_data["bpm4d-bpm4d"]["y"] == last_scan_data["bpm4d"]["bpm4d"].val
|
||||
|
||||
|
||||
def test_rpc_image(rpc_server_figure, bec_client_lib):
|
||||
fig = BECFigure(rpc_server_figure)
|
||||
def test_rpc_image(connected_client_figure, bec_client_lib):
|
||||
fig = BECFigure(connected_client_figure)
|
||||
|
||||
im = fig.image("eiger")
|
||||
|
||||
@@ -130,8 +130,8 @@ def test_rpc_image(rpc_server_figure, bec_client_lib):
|
||||
np.testing.assert_equal(last_image_device, last_image_plot)
|
||||
|
||||
|
||||
def test_rpc_motor_map(rpc_server_figure, bec_client_lib):
|
||||
fig = BECFigure(rpc_server_figure)
|
||||
def test_rpc_motor_map(connected_client_figure, bec_client_lib):
|
||||
fig = BECFigure(connected_client_figure)
|
||||
|
||||
motor_map = fig.motor_map("samx", "samy")
|
||||
|
||||
@@ -159,9 +159,9 @@ def test_rpc_motor_map(rpc_server_figure, bec_client_lib):
|
||||
)
|
||||
|
||||
|
||||
def test_dap_rpc(rpc_server_figure, bec_client_lib, qtbot):
|
||||
def test_dap_rpc(connected_client_figure, bec_client_lib, qtbot):
|
||||
|
||||
fig = BECFigure(rpc_server_figure)
|
||||
fig = BECFigure(connected_client_figure)
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
|
||||
|
||||
client = bec_client_lib
|
||||
@@ -199,8 +199,8 @@ def test_dap_rpc(rpc_server_figure, bec_client_lib, qtbot):
|
||||
qtbot.waitUntil(wait_for_fit, timeout=10000)
|
||||
|
||||
|
||||
def test_removing_subplots(rpc_server_figure, bec_client_lib):
|
||||
fig = BECFigure(rpc_server_figure)
|
||||
def test_removing_subplots(connected_client_figure, bec_client_lib):
|
||||
fig = BECFigure(connected_client_figure)
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
|
||||
im = fig.image(monitor="eiger")
|
||||
mm = fig.motor_map(motor_x="samx", motor_y="samy")
|
||||
|
||||
@@ -3,8 +3,8 @@ import pytest
|
||||
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
|
||||
|
||||
|
||||
def test_rpc_register_list_connections(rpc_server_figure):
|
||||
fig = BECFigure(rpc_server_figure)
|
||||
def test_rpc_register_list_connections(connected_client_figure):
|
||||
fig = BECFigure(connected_client_figure)
|
||||
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i")
|
||||
im = fig.image("eiger")
|
||||
|
||||
@@ -5,7 +5,7 @@ from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.qt_utils import error_popups
|
||||
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest import mock
|
||||
import pytest
|
||||
|
||||
from bec_widgets.cli.client import BECFigure
|
||||
from bec_widgets.cli.client_utils import BECGuiClientMixin, _start_plot_process
|
||||
from bec_widgets.cli.client_utils import BECGuiClient, _start_plot_process
|
||||
from bec_widgets.tests.utils import FakeDevice
|
||||
|
||||
|
||||
@@ -63,15 +63,14 @@ def test_client_utils_passes_client_config_to_server(bec_dispatcher):
|
||||
|
||||
@contextmanager
|
||||
def bec_client_mixin():
|
||||
mixin = BECGuiClientMixin()
|
||||
mixin = BECGuiClient()
|
||||
mixin._client = bec_dispatcher.client
|
||||
mixin._gui_id = "gui_id"
|
||||
mixin.gui_is_alive = mock.MagicMock()
|
||||
mixin.gui_is_alive.side_effect = [True]
|
||||
|
||||
try:
|
||||
with mock.patch.object(mixin, "_start_update_script"):
|
||||
yield mixin
|
||||
yield mixin
|
||||
finally:
|
||||
mixin.close()
|
||||
|
||||
@@ -82,6 +81,5 @@ def test_client_utils_passes_client_config_to_server(bec_dispatcher):
|
||||
wait=False
|
||||
) # the started event will not be set, wait=True would block forever
|
||||
mock_start_plot.assert_called_once_with(
|
||||
"gui_id", BECGuiClientMixin, mixin._client._service_config.config, logger=mock.ANY
|
||||
"gui_id", BECGuiClient, mixin._client._service_config.config, logger=mock.ANY
|
||||
)
|
||||
mixin._start_update_script.assert_called_once()
|
||||
|
||||
@@ -70,7 +70,7 @@ def test_client_generator_with_black_formatting():
|
||||
import enum
|
||||
from typing import Literal, Optional, overload
|
||||
|
||||
from bec_widgets.cli.client_utils import BECGuiClientMixin, RPCBase, rpc_call
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
|
||||
|
||||
# pylint: skip-file
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from bec_widgets.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
|
||||
|
||||
class FakeObject:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from bec_widgets.cli.rpc_wigdet_handler import RPCWidgetHandler
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import RPCWidgetHandler
|
||||
|
||||
|
||||
def test_rpc_widget_handler():
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||||
import pytest
|
||||
from qtpy.QtWidgets import QComboBox, QLineEdit, QSpinBox, QTableWidget, QVBoxLayout, QWidget
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QComboBox,
|
||||
QLineEdit,
|
||||
QSpinBox,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy, WidgetIO
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@@ -22,6 +31,12 @@ def example_widget(qtbot):
|
||||
# Add text items to the combo box
|
||||
combo_box.addItems(["Option 1", "Option 2", "Option 3"])
|
||||
|
||||
# Populate the table widget
|
||||
table_widget.setItem(0, 0, QTableWidgetItem("Initial A"))
|
||||
table_widget.setItem(0, 1, QTableWidgetItem("Initial B"))
|
||||
table_widget.setItem(1, 0, QTableWidgetItem("Initial C"))
|
||||
table_widget.setItem(1, 1, QTableWidgetItem("Initial D"))
|
||||
|
||||
qtbot.addWidget(main_widget)
|
||||
qtbot.waitExposed(main_widget)
|
||||
yield main_widget
|
||||
@@ -88,3 +103,73 @@ def test_export_import_config(example_widget):
|
||||
|
||||
assert exported_config_full == expected_full
|
||||
assert exported_config_reduced == expected_reduced
|
||||
|
||||
|
||||
def test_widget_io_get_set_value(example_widget):
|
||||
# Extract widgets
|
||||
line_edit = example_widget.findChild(QLineEdit)
|
||||
combo_box = example_widget.findChild(QComboBox)
|
||||
table_widget = example_widget.findChild(QTableWidget)
|
||||
spin_box = example_widget.findChild(QSpinBox)
|
||||
|
||||
# Check initial values
|
||||
assert WidgetIO.get_value(line_edit) == ""
|
||||
assert WidgetIO.get_value(combo_box) == 0 # first index
|
||||
assert WidgetIO.get_value(table_widget) == [
|
||||
["Initial A", "Initial B"],
|
||||
["Initial C", "Initial D"],
|
||||
]
|
||||
assert WidgetIO.get_value(spin_box) == 0
|
||||
|
||||
# Set new values
|
||||
WidgetIO.set_value(line_edit, "Hello")
|
||||
WidgetIO.set_value(combo_box, "Option 2")
|
||||
WidgetIO.set_value(table_widget, [["X", "Y"], ["Z", "W"]])
|
||||
WidgetIO.set_value(spin_box, 5)
|
||||
|
||||
# Check updated values
|
||||
assert WidgetIO.get_value(line_edit) == "Hello"
|
||||
assert WidgetIO.get_value(combo_box, as_string=True) == "Option 2"
|
||||
assert WidgetIO.get_value(table_widget) == [["X", "Y"], ["Z", "W"]]
|
||||
assert WidgetIO.get_value(spin_box) == 5
|
||||
|
||||
|
||||
def test_widget_io_signal(qtbot, example_widget):
|
||||
# Extract widgets
|
||||
line_edit = example_widget.findChild(QLineEdit)
|
||||
combo_box = example_widget.findChild(QComboBox)
|
||||
spin_box = example_widget.findChild(QSpinBox)
|
||||
table_widget = example_widget.findChild(QTableWidget)
|
||||
|
||||
# We'll store changes in a list to verify the slot is called
|
||||
changes = []
|
||||
|
||||
def universal_slot(w, val):
|
||||
changes.append((w, val))
|
||||
|
||||
# Connect signals
|
||||
WidgetIO.connect_widget_change_signal(line_edit, universal_slot)
|
||||
WidgetIO.connect_widget_change_signal(combo_box, universal_slot)
|
||||
WidgetIO.connect_widget_change_signal(spin_box, universal_slot)
|
||||
WidgetIO.connect_widget_change_signal(table_widget, universal_slot)
|
||||
|
||||
# Trigger changes
|
||||
line_edit.setText("NewText")
|
||||
qtbot.waitUntil(lambda: len(changes) > 0)
|
||||
assert changes[-1][1] == "NewText"
|
||||
|
||||
combo_box.setCurrentIndex(2)
|
||||
qtbot.waitUntil(lambda: len(changes) > 1)
|
||||
# combo_box change should give the current index or value
|
||||
# We set "Option 3" is index 2
|
||||
assert changes[-1][1] == 2 or changes[-1][1] == "Option 3"
|
||||
|
||||
spin_box.setValue(42)
|
||||
qtbot.waitUntil(lambda: len(changes) > 2)
|
||||
assert changes[-1][1] == 42
|
||||
|
||||
# For the table widget, changing a cell triggers cellChanged
|
||||
table_widget.setItem(0, 0, QTableWidgetItem("ChangedCell"))
|
||||
qtbot.waitUntil(lambda: len(changes) > 3)
|
||||
# The entire table value should be retrieved
|
||||
assert changes[-1][1][0][0] == "ChangedCell"
|
||||
|
||||
Reference in New Issue
Block a user