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

Compare commits

...

8 Commits

Author SHA1 Message Date
semantic-release
fab7dd7eec 0.63.1
Automatically generated by python-semantic-release
2024-06-13 13:12:54 +00:00
9263f8ef5c fix: just terminate the remote process in close() instead of communicating
The proper finalization sequence will be executed by the remote process
on SIGTERM
2024-06-13 14:56:21 +02:00
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
12 changed files with 343 additions and 113 deletions

View File

@@ -2,6 +2,41 @@
## v0.63.1 (2024-06-13)
### Fix
* fix: just terminate the remote process in close() instead of communicating
The proper finalization sequence will be executed by the remote process
on SIGTERM ([`9263f8e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9263f8ef5c17ae7a007a1a564baf787b39061756))
## 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
@@ -135,31 +170,3 @@
## v0.57.6 (2024-06-06)
### Fix
* fix(bar): docstrings extended ([`edb1775`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/edb1775967c3ff0723d0edad2b764f1ffc832b7c))
## 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",))
@@ -176,16 +174,11 @@ class BECGuiClientMixin:
"""
Close the figure.
"""
if self._process is None:
return
if self.gui_is_alive():
self._run_rpc("close", (), wait_for_rpc_response=True)
else:
self._run_rpc("close", (), wait_for_rpc_response=False)
self._process.terminate()
self._process_output_processing_thread.join()
self._process = None
self._client.shutdown()
if self._process:
self._process.terminate()
self._process_output_processing_thread.join()
self._process = None
def print_log(self) -> None:
"""
@@ -207,48 +200,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 +226,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 +248,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 +280,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

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.1"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [

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