1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-14 12:40:54 +02:00

Compare commits

..

6 Commits

8 changed files with 359 additions and 53 deletions

View File

@@ -2,6 +2,17 @@
## v0.62.0 (2024-06-12)
### Feature
* feat: implement non-polling, interruptible waiting of gui instruction response with timeout ([`abc6caa`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/abc6caa2d0b6141dfbe1f3d025f78ae14deddcb3))
### Unknown
* doc: add documentation about creating custom GUI applications embedding BEC Widgets ([`17a0068`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/17a00687579f5efab1990cd83862ec0e78198633))
## v0.61.0 (2024-06-12)
### Feature
@@ -152,12 +163,3 @@
### Fix
* fix(docks): set_title do update dock internal _name now ([`15cbc21`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/15cbc21e5bb3cf85f5822d44a2b3665b5aa2f346))
* fix(docks): docks widget_list adn dockarea panels return values fixed ([`ffae5ee`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ffae5ee54e6b43da660131092452adff195ba4fb))
## v0.57.3 (2024-06-06)
### Documentation
* docs(bar): docs updated ([`4be0d14`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4be0d14b7445c2322c2aef86257db168a841265c))

View File

@@ -14,7 +14,7 @@ from typing import TYPE_CHECKING
from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from
from qtpy.QtCore import QCoreApplication
from qtpy.QtCore import QEventLoop, QSocketNotifier, QTimer
import bec_widgets.cli.client as client
from bec_widgets.cli.auto_updates import AutoUpdates
@@ -24,6 +24,8 @@ if TYPE_CHECKING:
from bec_widgets.cli.client import BECDockArea, BECFigure
from bec_lib.serialization import MsgpackSerialization
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
@@ -205,6 +207,48 @@ class RPCResponseTimeoutError(Exception):
)
class QtRedisMessageWaiter:
def __init__(self, redis_connector, message_to_wait):
self.ev_loop = QEventLoop()
self.response = None
self.connector = redis_connector
self.message_to_wait = message_to_wait
self.pubsub = redis_connector._redis_conn.pubsub()
self.pubsub.subscribe(self.message_to_wait.endpoint)
fd = self.pubsub.connection._sock.fileno()
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
self.notifier.activated.connect(self._pubsub_readable)
def _msg_received(self, msg_obj):
self.response = msg_obj.value
self.ev_loop.quit()
def wait(self, timeout=1):
timer = QTimer()
timer.singleShot(timeout * 1000, self.ev_loop.quit)
self.ev_loop.exec_()
timer.stop()
self.notifier.setEnabled(False)
self.pubsub.close()
return self.response
def _pubsub_readable(self, fd):
while True:
msg = self.pubsub.get_message()
if msg:
if msg["type"] == "subscribe":
# get_message buffers, so we may already have the answer
# let's check...
continue
else:
break
else:
return
channel = msg["channel"].decode()
msg = MessageObject(topic=channel, value=MsgpackSerialization.loads(msg["data"]))
self.connector._execute_callback(self._msg_received, msg, {})
class RPCBase:
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
self._client = BECDispatcher().client
@@ -231,7 +275,7 @@ class RPCBase:
parent = parent._parent
return parent
def _run_rpc(self, method, *args, wait_for_rpc_response=True, **kwargs):
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
"""
Run the RPC call.
@@ -253,16 +297,24 @@ class RPCBase:
# pylint: disable=protected-access
receiver = self._root._gui_id
if wait_for_rpc_response:
redis_msg = QtRedisMessageWaiter(
self._client.connector, MessageEndpoints.gui_instruction_response(request_id)
)
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
if not wait_for_rpc_response:
return None
response = self._wait_for_response(request_id)
# get class name
if not response.content["accepted"]:
raise ValueError(response.content["message"]["error"])
msg_result = response.content["message"].get("result")
return self._create_widget_from_msg_result(msg_result)
if wait_for_rpc_response:
response = redis_msg.wait(timeout)
if response is None:
raise RPCResponseTimeoutError(request_id, timeout)
# get class name
if not response.accepted:
raise ValueError(response.message["error"])
msg_result = response.message.get("result")
return self._create_widget_from_msg_result(msg_result)
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
@@ -285,30 +337,6 @@ class RPCBase:
return cls(parent=self, **msg_result)
return msg_result
def _wait_for_response(self, request_id: str, timeout: int = 5):
"""
Wait for the response from the server.
Args:
request_id(str): The request ID.
timeout(int): The timeout in seconds.
Returns:
The response from the server.
"""
start_time = time.time()
response = None
while response is None and self.gui_is_alive() and (time.time() - start_time) < timeout:
response = self._client.connector.get(
MessageEndpoints.gui_instruction_response(request_id)
)
QCoreApplication.processEvents() # keep UI responsive (and execute signals/slots)
if response is None and (time.time() - start_time) >= timeout:
raise RPCResponseTimeoutError(request_id, timeout)
return response
def gui_is_alive(self):
"""
Check if the GUI is alive.

View File

@@ -136,17 +136,18 @@ if __name__ == "__main__": # pragma: no cover
module_path = os.path.dirname(bec_widgets.__file__)
app = QApplication(sys.argv)
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
qdarktheme.setup_theme("auto")
icon = QIcon()
icon.addFile(os.path.join(module_path, "assets", "terminal_icon.png"), size=QSize(48, 48))
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
app = QApplication(sys.argv)
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
# qdarktheme.setup_theme("auto")
icon = QIcon()
icon.addFile(os.path.join(module_path, "assets", "terminal_icon.png"), size=QSize(48, 48))
app.setWindowIcon(icon)
win = JupyterConsoleWindow()
win.show()

View File

@@ -9,7 +9,7 @@ import redis
from bec_lib.client import BECClient
from bec_lib.redis_connector import MessageObject, RedisConnector
from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QObject
from qtpy.QtCore import QCoreApplication, QObject
from qtpy.QtCore import Signal as pyqtSignal
if TYPE_CHECKING:
@@ -71,6 +71,7 @@ class BECDispatcher:
_instance = None
_initialized = False
qapp = None
def __new__(cls, client=None, config: str = None, *args, **kwargs):
if cls._instance is None:
@@ -82,6 +83,9 @@ class BECDispatcher:
if self._initialized:
return
if not QCoreApplication.instance():
BECDispatcher.qapp = QCoreApplication([])
self._slots = collections.defaultdict(set)
self.client = client

View File

View File

@@ -0,0 +1,159 @@
import logging
import os
import platform
import sys
import termqt
from qtpy.QtCore import QSocketNotifier, Qt
from qtpy.QtGui import QFont
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QWidget
from termqt import Terminal
try:
from qtpy.QtCore import pyqtRemoveInputHook
pyqtRemoveInputHook()
except ImportError:
pass
if platform.system() in ["Linux", "Darwin"]:
terminal_cmd = os.environ["SHELL"]
from termqt import TerminalPOSIXExecIO
class TerminalExecIO(TerminalPOSIXExecIO):
def _read_loop(self):
pass
def find_utf8_split(self, data):
"""UTF-8 characters can be 1-4 bytes long, this finds first index which is not mid character
Character lengths include:
1 Bytes: 0xxxxxxx
2 Bytes: 110xxxxx 10xxxxxx
3 Bytes: 1110xxxx 10xxxxxx 10xxxxxx
4 bytes: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
Source: https://en.wikipedia.org/wiki/UTF-8#Encoding
Start at end of chunk moving backwards, find first UTF-8 start byte:
1 Bytes: 0xxxxxxx - 0x80 == 0x00
2 Bytes: 110xxxxx - 0xE0 == 0xC0
3 Bytes: 1110xxxx - 0xF0 == 0xE0
4 bytes: 11110xxx - 0xF8 == 0xF0
Parameters:
data (bytes) - buffer to be evaluated
Returns:
(int) - last position of complete UTF-8 character
"""
pos = 0
for i, c in enumerate(reversed(data)):
if c & 0x80 == 0x00 or c & 0xE0 == 0xC0 or c & 0xF0 == 0xE0 or c & 0xF8 == 0xF0:
pos = i
break
return len(data) - pos
def _read(self, fd):
try:
data = os.read(fd, 2**16) # read as much as possible
except OSError:
data = b""
if data:
self._read_buf += data
i = self.find_utf8_split(self._read_buf)
output = self._read_buf[:i]
self._read_buf = self._read_buf[i:]
self.stdout_callback(output)
else:
self.logger.info("Spawned process has been killed")
if self.running:
self.running = False
self.terminated_callback()
os.close(fd)
def spawn(self):
super().spawn()
self._read_notifier = QSocketNotifier(self.fd, QSocketNotifier.Read)
self._read_notifier.activated.connect(self._read)
def write(self, buffer):
# same as original method, but without logging and without assert (unneeded)
if not self.running:
return
try:
os.write(self.fd, buffer)
except OSError:
self.running = False
self.terminated_callback()
else:
terminal_cmd = "cmd.exe"
from termqt import TerminalWinptyIO as TerminalExecIO
class TerminalWidget(QWidget):
def __init__(self, logger):
super().__init__()
self.logger = logger
self.terminal = Terminal(800, 600, logger=self.logger)
self.terminal.set_font()
self.terminal.maximum_line_history = 2000
self.scroll = QScrollBar(Qt.Vertical, self.terminal)
self.terminal.connect_scroll_bar(self.scroll)
layout = QHBoxLayout()
layout.addWidget(self.terminal)
layout.addWidget(self.scroll)
layout.setSpacing(0)
self.setLayout(layout)
class BECConsole(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle(f"termqt on {platform.system()}")
self.logger = self.setup_logger()
self.terminal_widget = TerminalWidget(self.logger)
layout = QHBoxLayout()
layout.addWidget(self.terminal_widget)
self.setLayout(layout)
self.auto_wrap_enabled = True
self.platform = platform.system()
self.setup_terminal_io()
def setup_logger(self):
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
handler = logging.StreamHandler()
formatter = logging.Formatter("[%(asctime)s] > [%(filename)s:%(lineno)d] %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
def setup_terminal_io(self):
self.terminal_io = TerminalExecIO(
self.terminal_widget.terminal.row_len,
self.terminal_widget.terminal.col_len,
terminal_cmd,
logger=self.logger,
)
self.auto_wrap_enabled = False
self.terminal_widget.terminal.enable_auto_wrap(self.auto_wrap_enabled)
self.terminal_io.stdout_callback = self.terminal_widget.terminal.stdout
self.terminal_widget.terminal.stdin_callback = self.terminal_io.write
self.terminal_widget.terminal.resize_callback = self.terminal_io.resize
self.terminal_io.spawn()
if __name__ == "__main__":
app = QApplication([])
main_window = BECConsole()
main_window.show()
sys.exit(app.exec())

View File

@@ -1,8 +1,118 @@
(user.customisation)=
# Customisation
BEC Widgets are designed to be used with QtDesigner to quicly design GUI.
## Leveraging BEC Widgets in custom GUI applications
BEC Widgets can be used to compose a complete Qt graphical application, along with
other QWidgets. The only requirement is to connect to BEC servers in order to get
data, or to interact with BEC components. This role is devoted to the BECDispatcher,
a singleton object which has to be instantiated **after the QApplication is created**.
A typical BEC Widgets custom application "main" entry point should follow the template
below:
```
import argparse
import sys
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from qtpy.QtWidgets import QApplication
# optional command line arguments processing
parser = argparse.ArgumentParser(description="...")
parser.add_argument( ...)
...
args = parser.parse_args()
# creation of the Qt application
app = QApplication([])
# creation of BEC Dispatcher
# /!\ important: after the QApplication has been instantiated
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
# (optional) processing of command line args,
# creation of a main window depending on the command line arguments (or not)
if args.xxx == "...":
window = ...
# display of the main window and start of Qt event loop
window.show()
sys.exit(app.exec())
```
The main "window" object presents the layout of widgets to the user and allows
users to interact. BEC Widgets must be placed in the window:
```
from qtpy.QWidgets import QMainWindow
from bec_widgets.widgets import BECFigure
window = QMainWindow()
bec_figure = BECFigure(gui_id="my_gui_app_id")
window.setCentralWidget(bec_figure)
# prepare to plot samx motor vs bpm4i value
bec_figure.plot(x_name="samx", y_name="bpm4i")
```
In the example just above, the resulting application will show a plot of samx
positions on the horizontal axis, and beam intensity on the vertical axis
(when the next scan will be started).
It is important to ensure proper cleanup of the resources is done when application
quits:
```
def final_cleanup():
bec_figure.clear_all()
bec_figure.client.shutdown()
window.aboutToQuit.connect(final_cleanup)
```
Final example:
```
import sys
from qtpy.QtWidgets import QMainWindow, QApplication
from bec_widgets.widgets import BECFigure
from bec_widgets.utils.bec_dispatcher import BECDispatcher
# creation of the Qt application
app = QApplication([])
# creation of BEC Dispatcher
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
# creation of main window
window = QMainWindow()
# inserting BEC Widgets
bec_figure = BECFigure(parent=window, gui_id="my_gui_app_id")
window.setCentralWidget(bec_figure)
bec_figure.plot(x_name="samx", y_name="bpm4i")
# ensuring proper cleanup
def final_cleanup():
bec_figure.clear_all()
bec_figure.client.shutdown()
app.aboutToQuit.connect(final_cleanup)
# execution
window.show()
sys.exit(app.exec())
```
## Writing applications using Qt Designer
BEC Widgets are designed to be used with QtDesigner to quickly design GUI.
## Example of promoting widgets in Qt Designer

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "0.61.0"
version = "0.62.0"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -25,6 +25,7 @@ dependencies = [
"pyqtdarktheme",
"black", # needed for bw-generate-cli
"isort", # needed for bw-generate-cli
"termqt @ git+ssh://git@github.com:TerryGeng/termqt.git"
]
@@ -36,6 +37,7 @@ dev = [
"pytest-xvfb",
"coverage",
"pytest-qt",
"isort",
"fakeredis",
]
pyqt5 = ["PyQt5>=5.9", "PyQtWebEngine>=5.9"]