mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-14 12:40:54 +02:00
Compare commits
6 Commits
v0.61.0
...
feat/bash-
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e52550f67 | |||
| 74182bb142 | |||
| 2b5068903a | |||
|
|
718950cf0d | ||
| 17a0068757 | |||
| abc6caa2d0 |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
0
bec_widgets/widgets/console/__init__.py
Normal file
0
bec_widgets/widgets/console/__init__.py
Normal file
159
bec_widgets/widgets/console/console.py
Normal file
159
bec_widgets/widgets/console/console.py
Normal 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())
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user