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

Compare commits

..

6 Commits

Author SHA1 Message Date
semantic-release
658728efef 0.63.0
Automatically generated by python-semantic-release
2024-06-13 12:47:57 +00:00
6b8432f5b2 refactor: add pydantic config, add change_theme 2024-06-13 14:08:22 +02:00
bc709c4184 docs: add documentation 2024-06-13 08:14:50 +02:00
b49462abeb test: add test for text box 2024-06-13 08:14:50 +02:00
d9d4e3c9bf feat: add textbox widget 2024-06-13 08:08:46 +02:00
fe04dd80e5 Revert "feat: implement non-polling, interruptible waiting of gui instruction response with timeout"
This reverts commit abc6caa2d0
2024-06-12 17:19:08 +02:00
13 changed files with 329 additions and 258 deletions

View File

@@ -2,6 +2,31 @@
## v0.63.0 (2024-06-13)
### Documentation
* docs: add documentation ([`bc709c4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bc709c4184c985d4e721f9ea7d1b3dad5e9153a7))
### Feature
* feat: add textbox widget ([`d9d4e3c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d9d4e3c9bf73ab2a5629c2867b50fc91e69489ec))
### Refactor
* refactor: add pydantic config, add change_theme ([`6b8432f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b8432f5b20a71175a3537b5f6832b76e3b67d73))
### Test
* test: add test for text box ([`b49462a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b49462abeb186e56bac79d2ef0b0add1ef28a1a5))
### Unknown
* Revert "feat: implement non-polling, interruptible waiting of gui instruction response with timeout"
This reverts commit abc6caa2d0b6141dfbe1f3d025f78ae14deddcb3 ([`fe04dd8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fe04dd80e59a0e74f7fdea603e0642707ecc7c2a))
## v0.62.0 (2024-06-12)
### Feature
@@ -142,24 +167,3 @@
## v0.57.5 (2024-06-06)
### Documentation
* docs(figure): docs adjusted to be compatible with new signature ([`c037b87`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c037b87675af91b26e8c7c60e76622d4ed4cf5d5))
### Fix
* fix(waveform): added .plot method with the same signature as BECFigure.plot ([`8479caf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8479caf53a7325788ca264e5bd9aee01f1d4c5a0))
* fix(plot_base): .plot removed from plot_base.py, because there is no use case for it ([`82e2c89`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/82e2c898d2e26f786b2d481f85c647472675e75b))
### Refactor
* refactor(figure): logic for .add_image and .image consolidated; logic for .add_plot and .plot consolidated ([`52bc322`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/52bc322b2b8d3ef92ff3480e61bddaf32464f976))
## v0.57.4 (2024-06-06)
### Fix
* fix(docks): set_title do update dock internal _name now ([`15cbc21`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/15cbc21e5bb3cf85f5822d44a2b3665b5aa2f346))

View File

@@ -17,6 +17,7 @@ class Widgets(str, enum.Enum):
BECDockArea = "BECDockArea"
BECFigure = "BECFigure"
SpiralProgressBar = "SpiralProgressBar"
TextBox = "TextBox"
WebsiteWidget = "WebsiteWidget"
@@ -1897,6 +1898,48 @@ class SpiralProgressBar(RPCBase):
"""
class StopButton(RPCBase):
@property
@rpc_call
def config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class TextBox(RPCBase):
@rpc_call
def set_color(self, background_color: str, font_color: str) -> None:
"""
Set the background color of the Widget.
Args:
background_color (str): The color to set the background in HEX.
font_color (str): The color to set the font in HEX.
"""
@rpc_call
def set_text(self, text: str) -> None:
"""
Set the text of the Widget
"""
@rpc_call
def set_font_size(self, size: int) -> None:
"""
Set the font size of the text in the Widget.
"""
class WebsiteWidget(RPCBase):
@rpc_call
def set_url(self, url: str) -> None:

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 QEventLoop, QSocketNotifier, QTimer
from qtpy.QtCore import QCoreApplication
import bec_widgets.cli.client as client
from bec_widgets.cli.auto_updates import AutoUpdates
@@ -24,8 +24,6 @@ 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",))
@@ -207,48 +205,6 @@ 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
@@ -275,7 +231,7 @@ class RPCBase:
parent = parent._parent
return parent
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
def _run_rpc(self, method, *args, wait_for_rpc_response=True, **kwargs):
"""
Run the RPC call.
@@ -297,24 +253,16 @@ 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 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)
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)
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
@@ -337,6 +285,30 @@ 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

@@ -1,6 +1,7 @@
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.spiral_progress_bar.spiral_progress_bar import SpiralProgressBar
from bec_widgets.widgets.text_box.text_box import TextBox
from bec_widgets.widgets.website.website import WebsiteWidget
@@ -11,6 +12,7 @@ class RPCWidgetHandler:
"BECFigure": BECFigure,
"SpiralProgressBar": SpiralProgressBar,
"Website": WebsiteWidget,
"TextBox": TextBox,
}
@staticmethod

View File

@@ -136,18 +136,17 @@ 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 QCoreApplication, QObject
from qtpy.QtCore import QObject
from qtpy.QtCore import Signal as pyqtSignal
if TYPE_CHECKING:
@@ -71,7 +71,6 @@ class BECDispatcher:
_instance = None
_initialized = False
qapp = None
def __new__(cls, client=None, config: str = None, *args, **kwargs):
if cls._instance is None:
@@ -83,9 +82,6 @@ class BECDispatcher:
if self._initialized:
return
if not QCoreApplication.instance():
BECDispatcher.qapp = QCoreApplication([])
self._slots = collections.defaultdict(set)
self.client = client

View File

@@ -1,159 +0,0 @@
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

@@ -0,0 +1,127 @@
import re
from pydantic import Field, field_validator
from qtpy.QtWidgets import QTextEdit
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import Colors
class TextBoxConfig(ConnectionConfig):
theme: str = Field("dark", description="The theme of the figure widget.")
font_color: str = Field("#FFF", description="The font color of the text")
background_color: str = Field("#000", description="The background color of the widget.")
font_size: int = Field(16, description="The font size of the text in the widget.")
text: str = Field("", description="The text to display in the widget.")
@classmethod
@field_validator("theme")
def validate_theme(cls, v):
"""Validate the theme of the figure widget."""
if v not in ["dark", "light"]:
raise ValueError("Theme must be either 'dark' or 'light'")
return v
_validate_font_color = field_validator("font_color")(Colors.validate_color)
_validate_background_color = field_validator("background_color")(Colors.validate_color)
class TextBox(BECConnector, QTextEdit):
USER_ACCESS = ["set_color", "set_text", "set_font_size"]
def __init__(self, text: str = "", parent=None, client=None, config=None, gui_id=None):
if config is None:
config = TextBoxConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = TextBoxConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
QTextEdit.__init__(self, parent=parent)
self.config = config
self.setReadOnly(True)
self.setGeometry(self.rect())
self.set_color(self.config.background_color, self.config.font_color)
if not text:
text = "<h1>Welcome to the BEC Widget TextBox</h1><p>A widget that allows user to display text in plain and HTML format.</p><p>This is an example of displaying HTML text.</p>"
self.set_text(text)
def change_theme(self) -> None:
"""
Change the theme of the figure widget.
"""
if self.config.theme == "dark":
theme = "light"
font_color = "#000"
background_color = "#FFF"
else:
theme = "dark"
font_color = "#FFF"
background_color = "#000"
self.config.theme = theme
self.set_color(background_color, font_color)
def set_color(self, background_color: str, font_color: str) -> None:
"""Set the background color of the widget.
Args:
background_color (str): The color to set the background in HEX.
font_color (str): The color to set the font in HEX.
"""
self.config.background_color = background_color
self.config.font_color = font_color
self._update_stylesheet()
def set_font_size(self, size: int) -> None:
"""Set the font size of the text in the widget.
Args:
size (int): The font size to set.
"""
self.config.font_size = size
self._update_stylesheet()
def _update_stylesheet(self):
"""Update the stylesheet of the widget."""
self.setStyleSheet(
f"background-color: {self.config.background_color}; color: {self.config.font_color}; font-size: {self.config.font_size}px"
)
def set_text(self, text: str) -> None:
"""Set the text of the widget.
Args:
text (str): The text to set.
"""
if self.is_html(text):
self.setHtml(text)
else:
self.setPlainText(text)
self.config.text = text
def is_html(self, text: str) -> bool:
"""Check if the text contains HTML tags.
Args:
text (str): The text to check.
Returns:
bool: True if the text contains HTML tags, False otherwise.
"""
return bool(re.search(r"<[a-zA-Z/][^>]*>", text))
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = TextBox()
widget.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,33 @@
(user.widgets.text_box)=
# [Text Box Widget](/api_reference/_autosummary/bec_widgets.cli.client.TextBoxWidget)
**Purpose:**
The Text Box Widget is a widget that allows you to display text within the BEC GUI. The widget can be used to display plain text or HTML text.
**Key Features:**
- set the text to display.
- automatically detects if the text is plain text or HTML text.
- set background color and font color.
**Code example:**
The following code snipped demonstrates how to create a `TextBox` widget using BEC Widgets within BEC.
```python
text_box = gui.add_dock().add_widget("TextBox")
# set the text to display
text_box.set_text("Hello, World!")
# set the background color and font color
text_box.set_color(backgroud_color="#FFF", font_color="#000")
# set the text to display as HTML
text_box.set_text("<h1>Welcome to BEC Widgets</h1><p>This is an example of displaying <strong>HTML</strong> text.</p>")
```

View File

@@ -12,6 +12,7 @@ bec_figure/
spiral_progress_bar/
website/
buttons/
text_box/
```

View File

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

View File

@@ -0,0 +1,55 @@
import re
from unittest import mock
import pytest
from bec_widgets.widgets.text_box.text_box import TextBox
from .client_mocks import mocked_client
@pytest.fixture
def text_box_widget(qtbot, mocked_client):
widget = TextBox(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
def test_textbox_widget(text_box_widget):
"""Test the TextBox widget."""
text = "Hello World!"
text_box_widget.set_text(text)
assert text_box_widget.toPlainText() == text
text_box_widget.set_color("#FFDDC1", "#123456")
text_box_widget.set_font_size(20)
assert (
text_box_widget.styleSheet() == "background-color: #FFDDC1; color: #123456; font-size: 20px"
)
text_box_widget.set_color("white", "blue")
text_box_widget.set_font_size(14)
assert text_box_widget.styleSheet() == "background-color: white; color: blue; font-size: 14px"
text = "<h1>Welcome to PyQt6</h1><p>This is an example of displaying <strong>HTML</strong> text.</p>"
with mock.patch.object(text_box_widget, "setHtml") as mocked_set_html:
text_box_widget.set_text(text)
assert mocked_set_html.call_count == 1
assert mocked_set_html.call_args == mock.call(text)
def test_textbox_change_theme(text_box_widget):
"""Test change theme functionaility"""
# Default is dark theme
text_box_widget.change_theme()
assert text_box_widget.config.theme == "light"
assert (
text_box_widget.styleSheet()
== f"background-color: #FFF; color: #000; font-size: {text_box_widget.config.font_size}px"
)
text_box_widget.change_theme()
assert text_box_widget.config.theme == "dark"
assert (
text_box_widget.styleSheet()
== f"background-color: #000; color: #FFF; font-size: {text_box_widget.config.font_size}px"
)