1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 09:47:52 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
bdbc2b903d docs: add tutorial on how to add StartScan Button 2024-06-14 14:30:40 +02:00
139 changed files with 2353 additions and 7883 deletions

View File

@@ -1,151 +1,176 @@
# CHANGELOG
## v0.79.2 (2024-07-04)
## v0.63.2 (2024-06-14)
### Fix
* fix: overwrite closeEvent and call super class ([`bc0ef78`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bc0ef7893ef100b71b62101c459655509b534a56))
* fix: do not import "server" in client, prevents from having trouble with QApplication creation order
## v0.79.1 (2024-07-03)
### Fix
* fix: use libdir env var to preload Python library, also for Linux platform ([`d7718d4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d7718d4dcb9728c050b6421388af4d484f3741f2))
## v0.79.0 (2024-07-03)
### Feature
* feat(motor_map_widget): standalone MotorMap Widget with toolbar + plugin ([`6e75642`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6e756420907d7093557e945bc92bc4cfc0138d07))
* feat(motor_map): method to reset history trace ([`5960918`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5960918137dd41cdeb94e50f8abc4f169cf45c11))
### Fix
* fix(toolbar): change default color to black to match BECFigure theme ([`b8774e0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b8774e0b0bc43dcd00f94f42539a778e507ca27d))
* fix(motor_map): fixed bug with residual trace after changing motors ([`aaa0d10`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/aaa0d1003d2e94b45bafe4f700852c2c05288aea))
* fix(widget_io): widget handler adjusted for spinboxes and comboboxes ([`3dc0532`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3dc0532df05b6ec0a2522107fa0b1e210ce7d91b))
### Refactor
* refactor(toolbar): cleanup and adjusted colors ([`96863ad`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/96863adf53c15112645d20eb6200733617801c6d))
## v0.78.1 (2024-07-02)
### Fix
* fix(ui_loader): ui loader is compatible with bec plugins ([`b787759`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b787759f44486dc7af2c03811efb156041e4b6cb))
## v0.78.0 (2024-07-02)
### Feature
* feat(color_button): patched ColorButton from pyqtgraph to be able to be opened in another QDialog ([`c36bb80`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c36bb80d6a4939802a4a1c8e5452c7b94bac185e))
## v0.77.0 (2024-07-02)
### Feature
* feat(bec_connector): export config to yaml ([`a391f30`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a391f3018c50fee6a4a06884491b957df80c3cd3))
* feat(utils): colors added convertor for rgba to hex ([`572f2fb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/572f2fb8110d5cb0e80f3ca45ce57ef405572456))
### Fix
* fix(waveform): scatter 2D brush error ([`215d59c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/215d59c8bfe7fda9aff8cec8353bef9e1ce2eca1))
* fix(figure): API cleanup ([`008a33a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/008a33a9b192473cc58e90cd6d98c5bcb5f7b8c0))
* fix(figure): if/else logic corrected in subplot_factory ([`3e78723`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3e787234c7274b0698423d7bf9a4c54ec46bad5f))
* fix(image): processing of already displayed data; closes #106 ([`1173510`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1173510105d2d70d7e498c2ac1e122cea3a16597))
* fix(bec_figure): full reconstruction with config from other bec figure ([`b6e1e20`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b6e1e20b7c8549bb092e981062329e601411dda6))
* fix(motor_map): API changes updates current visualisation; motor_map can be initialised from config ([`2e2d422`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2e2d422910685a2527a3d961a468c787f771ca44))
* fix(image): image add_custom_image fixed, closes #225 ([`f0556e4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f0556e44113ffee66cf735aa2dd758c62cb634f4))
* fix(figure): subplot methods consolidated; added subplot factory ([`4a97105`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4a97105e4bd2ce77d72dfe5f8307dd9ee65b21b0))
* fix(image): image can be fully reconstructed from config ([`797f73c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/797f73c39aa73e07d6311f3de4baea53f6c380e0))
* fix(image_item): vrange added int for pydantic model check ([`b8f796f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b8f796fd3fcc15641e8fc6a3ca75c344ce90fc45))
* fix(bec_figure): waveforms can be initialised from the config; widgets are deleteLater after removal ([`78673ea`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/78673ea11a47aad878128197ae6213925228ed59))
Like with QtWebEngine ([`6f96498`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6f96498de66358b89f3a2035627eed2e02dde5a1))
### Unknown
* Resolve "add VT100 console executing BEC as a widget" ([`c6a14c0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c6a14c0768a90695567a83a7895247ed0c64f3ce))
* Reapply "feat: implement non-polling, interruptible waiting of gui instruction response with timeout"
## v0.76.1 (2024-06-29)
This reverts commit fe04dd80e59a0e74f7fdea603e0642707ecc7c2a. ([`836b6e6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/836b6e64f694916d6b6f909dedf11a4a6d2c86a4))
## v0.63.1 (2024-06-13)
### Fix
* fix(plugins): fixes and tests for auto-gen plugins ([`c42511d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c42511dd44cc13577e108a6cef3166376e594f54))
* fix: just terminate the remote process in close() instead of communicating
## v0.76.0 (2024-06-28)
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(designer): added support for creating designer plugins automatically ([`c1dd0ee`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c1dd0ee1906dba1f2e2ae9ce40a84d55c26a1cce))
### Fix
* fix: fixed qwidget inheritance for ring progress bar ([`0610d2f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0610d2f9f027f8659e7149f2dfbb316ff30e337d))
### Unknown
* fix:parent set as first kwarg TextBox and WebsiteWidget ([`a45c407`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a45c4075684b93bfdcee03e5a416b84f61d3bc6f))
## v0.75.0 (2024-06-26)
### Feature
* feat(widgets): added simple bec queue widget ([`3faee98`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3faee98ec80041a27e4c1f1156178de6f9dcdc63))
* feat: add textbox widget ([`d9d4e3c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d9d4e3c9bf73ab2a5629c2867b50fc91e69489ec))
### Refactor
* refactor(dispatcher): cleanup ([`ca02132`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ca02132c8d18535b37e9192e00459d2aca6ba5cf))
* refactor: add pydantic config, add change_theme ([`6b8432f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b8432f5b20a71175a3537b5f6832b76e3b67d73))
## v0.74.1 (2024-06-26)
### 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
* 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
* feat(widgets/stop_button): General stop button added ([`61ba08d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/61ba08d0b8df9f48f5c54c7c2b4e6d395206e7e6))
### Refactor
* refactor: improve labe of auto_update script ([`40b5688`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/40b568815893cd41af3531bb2e647ca1e2e315f4))
## v0.60.0 (2024-06-08)
### Ci
* ci: added git fetch for target branch ([`fc4f4f8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fc4f4f81ad1be99cf5112f2188a46c5bed2679ee))
* ci: fixed pylint-check ([`6b1d582`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b1d5827d6599f06a3acd316060a8d25f0686d54))
* ci: cleanup ([`11173b9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/11173b9c0a7dc4b36e35962042e5b86407da49f1))
### Feature
* feat: added isort to bw-generate-cli ([`f0391f5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f0391f59c9eb0a51b693fccfe2e399e869d35dda))
* feat: added entry point for bw-generate-cli ([`1c7f491`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1c7f4912ce5998e666276969bf4af8656d619a91))
* feat(cli): auto-discover rpc-enabled widgets ([`df1be10`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/df1be10057a5e85a3f35bef1c1b27366b6727276))
### Fix
* fix: removed BECConnector from rpc client interface ([`6428e38`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6428e38ab94c15a2c904e75cc6404bb6d0394e04))
* fix: added bec_ipython_client as dependency; needed for jupyter widget ([`006a089`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/006a0894b85cba3b2773737ed6fe3e92c81cdee0))
* fix(BECFigure): removed duplicated user access for plot ([`954c576`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/954c576131f7deac669ddf9f51eeb1d41b6f92b7))
* fix(bec_connector): field validator should be a classmethod ([`867720a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/867720a897b6713bd0df9af71ffdd11a6a380f7d))
### Refactor
* refactor: minor cleanup ([`3adf6cf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3adf6cfd586355c8b8ce7fdc9722f868e22287c5))
* refactor: disabled pylint for auto-gen client ([`b15816c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b15816ca9fd3e4ae87cca5fcfe029b4dfca570ca))
* refactor(isort): added bec_widgets as known first party package ([`9c5a471`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9c5a471234ed2928e4527b079436db2a807c5f6f))
* refactor(dock): parent_dock_area changed to orig_area (native for pyqtgraph) ([`2b40602`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2b40602bdc593ece0447ec926c2100414bd5cf67))
### Test
* test: added missing pylint statement to header ([`f662985`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f6629852ebc2b4ee239fa560cc310a5ae2627cf7))
## v0.59.1 (2024-06-07)
### Fix
* fix(curve): set_color_map_z typo fixed in user access ([`e7838b0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e7838b0f2fc23b0a232ed7d68fbd7f3493a91b9e))
## v0.59.0 (2024-06-07)
### Build
* build: added missing pytest-bec-e2e dependency; closes #219 ([`56fdae4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/56fdae42757bdb9fa301c1e425a77e98b6eaf92b))
* build: added webengine dependency ([`d56c549`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d56c5493cd28f379d04a79d90b01c73b0760da1b))
* build: fixed dependency ranges; closes #135 ([`e6a06c9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e6a06c9f43e0ad6bbfcfa550a2f580d2a27aff66))
### Ci
### Chore
* ci: merged additional tests to parallel matrix job ([`178fe4d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/178fe4d2da3a959f7cd90e7ea0f47314dc1ef4ed))
* chore: sorted dependencies alphabetically ([`21c807f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/21c807f35831fdd1ef2e488ab90edae4719f0cb7))
* ci: added webengine dependencies ([`2d79ef8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2d79ef8fe5e52c61f4a78782770377cd6b41958b))
### Documentation
* docs: fixed doc string ([`f979a63`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f979a63d3d1a008f80e500510909750878ff4303))
### Fix
* fix(rings): rings properties updated right after setting ([`c8b7367`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c8b7367815b095f8e4aa8b819481efb701f2e542))
* fix(motor_map): motor map can be removed from BECFigure with .remove() ([`6b25abf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b25abff70280271e2eeb70450553c05d4b7c99c))
### Test
* test(bec_figure): tests for removing widgets with rpc e2e ([`a268caa`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a268caaa30711fcc7ece542d24578d74cbf65c77))
## v0.74.0 (2024-06-25)
### Documentation
* docs(becfigure): docs added ([`a51b15d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a51b15da3f5e83e0c897a0342bdb05b9c677a179))
* docs: added website docs ([`cf6e5a4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cf6e5a40fc8320e9898a446a5bf14b77e94ef013))
### Feature
* feat(waveform1d): dap LMFit model can be added to plot ([`1866ba6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1866ba66c8e3526661beb13fff3e13af6a0ae562))
* feat(widget): added simple website widget with rpc ([`64abd67`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/64abd67b9b416bff9c89880b248d6e8639aa1e70))
## v0.58.1 (2024-06-07)
### Fix
* fix(dock): new dock can be detached upon creation ([`02a2608`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/02a26086c4540127a11c235cba30afc4fd712007))
## v0.58.0 (2024-06-07)
### Feature
* feat(utils.colors): general color validators ([`3094632`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/30946321348abc349fb4003dc39d0232dc19606c))
### Fix
* fix: bar colormap dynamic setting ([`67fd5e8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/67fd5e8581f60fe64027ac57f1f12cefa4d28343))
* fix: formatting isort ([`bf699ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bf699ec1fbe2aacd31854e84fb0438c336840fcf))
* fix(curve): 2D scatter updated if color_map_z is changed ([`6985ff0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6985ff0fcef9791b53198206ec8cbccd1d65ef99))
* fix(curve): color_map_z setting works ([`33f7be4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/33f7be42c512402dab3fdd9781a8234e3ec5f4ba))
### Test
* test(waveform1d): dap e2e test added ([`7271b42`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7271b422f98ef9264970d708811c414b69a644db))
* test(color): validation tests added ([`c0ddece`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c0ddeceeeabacbf33019a8f24b18821926dc17ac))
## v0.57.7 (2024-06-07)

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,6 @@ from functools import wraps
from typing import TYPE_CHECKING
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
from qtpy.QtCore import QEventLoop, QSocketNotifier, QTimer
@@ -32,8 +31,6 @@ messages = lazy_import("bec_lib.messages")
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):
"""
@@ -66,64 +63,45 @@ def rpc_call(func):
return wrapper
def _get_output(process, logger) -> None:
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
stream_buffer = {process.stdout: [], process.stderr: []}
def _get_output(process) -> None:
try:
os.set_blocking(process.stdout.fileno(), False)
os.set_blocking(process.stderr.fileno(), False)
while process.poll() is None:
readylist, _, _ = select.select([process.stdout, process.stderr], [], [], 1)
for stream in (process.stdout, process.stderr):
buf = stream_buffer[stream]
if stream in readylist:
buf.append(stream.read(4096))
output, _, remaining = "".join(buf).rpartition("\n")
if process.stdout in readylist:
output = process.stdout.read(1024)
if output:
log_func[stream](output)
buf.clear()
buf.append(remaining)
print(output, end="")
if process.stderr in readylist:
error_output = process.stderr.read(1024)
if error_output:
print(error_output, end="", file=sys.stderr)
except Exception as e:
print(f"Error reading process output: {str(e)}")
def _start_plot_process(gui_id, gui_class, config, logger=None) -> None:
def _start_plot_process(gui_id, gui_class, config) -> None:
"""
Start the plot in a new process.
Logger must be a logger object with "debug" and "error" functions,
or it can be left to "None" as default. None means output from the
process will not be captured.
"""
# pylint: disable=subprocess-run-check
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__]
if config:
command.extend(["--config", config])
command = [
"bec-gui-server",
"--id",
gui_id,
"--config",
config,
"--gui_class",
gui_class.__name__,
]
env_dict = os.environ.copy()
env_dict["PYTHONUNBUFFERED"] = "1"
if logger is None:
stdout_redirect = subprocess.DEVNULL
stderr_redirect = subprocess.DEVNULL
else:
stdout_redirect = subprocess.PIPE
stderr_redirect = subprocess.PIPE
process = subprocess.Popen(
command,
text=True,
start_new_session=True,
stdout=stdout_redirect,
stderr=stderr_redirect,
env=env_dict,
command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env_dict
)
if logger is None:
process_output_processing_thread = None
else:
process_output_processing_thread = threading.Thread(
target=_get_output, args=(process, logger)
)
process_output_processing_thread.start()
process_output_processing_thread = threading.Thread(target=_get_output, args=(process,))
process_output_processing_thread.start()
return process, process_output_processing_thread
@@ -135,16 +113,13 @@ class BECGuiClientMixin:
self.auto_updates = self._get_update_script()
self._target_endpoint = MessageEndpoints.scan_status()
self._selected_device = None
self.stderr_output = []
def _get_update_script(self) -> AutoUpdates | None:
eps = imd.entry_points(group="bec.widgets.auto_updates")
for ep in eps:
if ep.name == "plugin_widgets_update":
try:
spec = importlib.util.find_spec(ep.module)
# if the module is not found, we skip it
if spec is None:
continue
return ep.load()(gui=self)
except Exception as e:
print(f"Error loading auto update script from plugin: {str(e)}")
@@ -190,7 +165,7 @@ class BECGuiClientMixin:
if self._process is None or self._process.poll() is not None:
self._start_update_script()
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id, self.__class__, self._client._service_config.config_path
self._gui_id, self.__class__, self._client._service_config.redis
)
while not self.gui_is_alive():
print("Waiting for GUI to start...")
@@ -198,19 +173,24 @@ class BECGuiClientMixin:
def close(self) -> None:
"""
Close the gui window.
Close the figure.
"""
if self._process is None:
return
self._client.shutdown()
if self._process:
self._process.terminate()
if self._process_output_processing_thread:
self._process_output_processing_thread.join()
self._process.wait()
self._process_output_processing_thread.join()
self._process = None
def print_log(self) -> None:
"""
Print the log of the plot process.
"""
if self._process is None:
return
print("".join(self.stderr_output))
# Flush list
self.stderr_output.clear()
class RPCResponseTimeoutError(Exception):
"""Exception raised when an RPC response is not received within the expected time."""

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import argparse
import importlib
import inspect
import os
import sys
@@ -9,9 +10,9 @@ from typing import Literal
import black
import isort
from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
from bec_widgets.utils.plugin_utils import get_rpc_classes
from bec_widgets.utils import BECConnector
if sys.version_info >= (3, 11):
from typing import get_overloads
@@ -84,9 +85,6 @@ class {class_name}(RPCBase, BECGuiClientMixin):"""
else:
self.content += f"""
class {class_name}(RPCBase):"""
if not cls.USER_ACCESS:
self.content += """...
"""
for method in cls.USER_ACCESS:
obj = getattr(cls, method)
if isinstance(obj, property):
@@ -140,6 +138,50 @@ class {class_name}(RPCBase):"""
with open(file_name, "w", encoding="utf-8") as file:
file.write(formatted_content)
@staticmethod
def get_rpc_classes(
repo_name: str,
) -> dict[Literal["connector_classes", "top_level_classes"], list[type]]:
"""
Get all RPC-enabled classes in the specified repository.
Args:
repo_name(str): The name of the repository.
Returns:
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
connector_classes = []
top_level_classes = []
anchor_module = importlib.import_module(f"{repo_name}.widgets")
directory = os.path.dirname(anchor_module.__file__)
for root, _, files in sorted(os.walk(directory)):
for file in files:
if not file.endswith(".py") or file.startswith("__"):
continue
path = os.path.join(root, file)
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
if len(subs) == 1 and not subs[0]:
module_name = file.split(".")[0]
else:
module_name = ".".join(subs + [file.split(".")[0]])
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
for name in dir(module):
obj = getattr(module, name)
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
continue
if isinstance(obj, type) and issubclass(obj, BECConnector):
connector_classes.append(obj)
if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
top_level_classes.append(obj)
return {"connector_classes": connector_classes, "top_level_classes": top_level_classes}
def main():
"""
@@ -155,33 +197,13 @@ def main():
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
rpc_classes = get_rpc_classes("bec_widgets")
rpc_classes = ClientGenerator.get_rpc_classes("bec_widgets")
rpc_classes["connector_classes"].sort(key=lambda x: x.__name__)
generator = ClientGenerator()
generator.generate_client(rpc_classes)
generator.write(client_path)
for cls in rpc_classes["top_level_classes"]:
plugin = DesignerPluginGenerator(cls)
if not hasattr(plugin, "info"):
continue
# if the class directory already has a register, plugin and pyproject file, skip
if os.path.exists(
os.path.join(plugin.info.base_path, f"register_{plugin.info.plugin_name_snake}.py")
):
continue
if os.path.exists(
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}_plugin.py")
):
continue
if os.path.exists(
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}.pyproject")
):
continue
plugin.run()
if __name__ == "__main__": # pragma: no cover
sys.argv = ["generate_cli.py", "--core"]

View File

@@ -1,37 +1,22 @@
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
class RPCWidgetHandler:
"""Handler class for creating widgets from RPC messages."""
def __init__(self):
self._widget_classes = None
widget_classes = {
"BECFigure": BECFigure,
"SpiralProgressBar": SpiralProgressBar,
"Website": WebsiteWidget,
"TextBox": TextBox,
}
@property
def widget_classes(self):
"""
Get the available widget classes.
Returns:
dict: The available widget classes.
"""
if self._widget_classes is None:
self.update_available_widgets()
return self._widget_classes
def update_available_widgets(self):
"""
Update the available widgets.
Returns:
None
"""
from bec_widgets.utils.plugin_utils import get_rpc_classes
clss = get_rpc_classes("bec_widgets")
self._widget_classes = {cls.__name__: cls for cls in clss["top_level_classes"]}
def create_widget(self, widget_type, **kwargs) -> BECConnector:
@staticmethod
def create_widget(widget_type, **kwargs) -> BECConnector:
"""
Create a widget from an RPC message.
@@ -42,12 +27,7 @@ class RPCWidgetHandler:
Returns:
widget(BECConnector): The created widget.
"""
if self._widget_classes is None:
self.update_available_widgets()
widget_class = self._widget_classes.get(widget_type)
widget_class = RPCWidgetHandler.widget_classes.get(widget_type)
if widget_class:
return widget_class(**kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")
widget_handler = RPCWidgetHandler()

View File

@@ -1,26 +1,17 @@
from __future__ import annotations
import inspect
import signal
import sys
from contextlib import redirect_stderr, redirect_stdout
from typing import Union
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import QTimer
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
class BECWidgetsCLIServer:
@@ -31,7 +22,7 @@ class BECWidgetsCLIServer:
dispatcher: BECDispatcher = None,
client=None,
config=None,
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
gui_class: Union["BECFigure", "BECDockArea"] = BECFigure,
) -> None:
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
@@ -49,7 +40,7 @@ class BECWidgetsCLIServer:
self._shutdown_event = False
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200)
self._heartbeat_timer.start(200) # Emit heartbeat every 1 seconds
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
@@ -114,33 +105,15 @@ class BECWidgetsCLIServer:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=1, info={}),
expire=1,
expire=10,
)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
self._shutdown_event = True
self._heartbeat_timer.stop()
self.gui.close()
self.client.shutdown()
class SimpleFileLikeFromLogOutputFunc:
def __init__(self, log_func):
self._log_func = log_func
def write(self, buffer):
for line in buffer.rstrip().splitlines():
line = line.rstrip()
if line:
self._log_func(line)
def flush(self):
return
def close(self):
return
def main():
import argparse
import os
@@ -152,6 +125,16 @@ def main():
import bec_widgets
app = QApplication(sys.argv)
app.setApplicationName("BEC Figure")
module_path = os.path.dirname(bec_widgets.__file__)
icon = QIcon()
icon.addFile(os.path.join(module_path, "assets", "bec_widgets_icon.png"), size=QSize(48, 48))
app.setWindowIcon(icon)
win = QMainWindow()
win.setWindowTitle("BEC Widgets")
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
parser.add_argument("--id", type=str, help="The id of the server")
parser.add_argument(
@@ -159,7 +142,7 @@ def main():
type=str,
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
)
parser.add_argument("--config", type=str, help="Config file")
parser.add_argument("--config", type=str, help="Config to connect to redis.")
args = parser.parse_args()
@@ -174,45 +157,15 @@ def main():
)
gui_class = BECFigure
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.debug)):
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
app = QApplication(sys.argv)
app.setApplicationName("BEC Figure")
module_path = os.path.dirname(bec_widgets.__file__)
icon = QIcon()
icon.addFile(
os.path.join(module_path, "assets", "bec_widgets_icon.png"), size=QSize(48, 48)
)
app.setWindowIcon(icon)
server = BECWidgetsCLIServer(gui_id=args.id, config=args.config, gui_class=gui_class)
win = QMainWindow()
win.setWindowTitle("BEC Widgets")
gui = server.gui
win.setCentralWidget(gui)
win.resize(800, 600)
win.show()
service_config = ServiceConfig(args.config)
bec_logger.configure(
service_config.redis,
QtRedisConnector,
service_name="BECWidgetsCLIServer",
service_config=service_config.service_config,
)
server = BECWidgetsCLIServer(gui_id=args.id, config=service_config, gui_class=gui_class)
gui = server.gui
win.setCentralWidget(gui)
win.resize(800, 600)
win.show()
app.aboutToQuit.connect(server.shutdown)
def sigint_handler(*args):
# display message, for people to let it terminate gracefully
print("Caught SIGINT, exiting")
app.quit()
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler)
sys.exit(app.exec())
app.aboutToQuit.connect(server.shutdown)
sys.exit(app.exec())
if __name__ == "__main__": # pragma: no cover

View File

@@ -10,10 +10,25 @@ from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils import BECDispatcher, UILoader
from bec_widgets.widgets import BECFigure
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.jupyter_console.jupyter_console import BECJupyterConsole
# class JupyterConsoleWidget(RichJupyterWidget): # pragma: no cover:
# def __init__(self):
# super().__init__()
#
# self.kernel_manager = QtInProcessKernelManager()
# self.kernel_manager.start_kernel(show_banner=False)
# self.kernel_client = self.kernel_manager.client()
# self.kernel_client.start_channels()
#
# self.kernel_manager.kernel.shell.push({"np": np, "pg": pg})
#
# def shutdown_kernel(self):
# self.kernel_client.stop_channels()
# self.kernel_manager.shutdown_kernel()
class JupyterConsoleWindow(QWidget): # pragma: no cover:
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API)."""
@@ -40,14 +55,12 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"w1": self.w1,
"w2": self.w2,
"w3": self.w3,
"w1_c": self.w1_c,
"w2_c": self.w2_c,
"w3_c": self.w3_c,
"w4": self.w4,
"d0": self.d0,
"d1": self.d1,
"d2": self.d2,
"plt": self.plt,
"fig0": self.fig0,
"fig1": self.fig1,
"fig2": self.fig2,
"bar": self.bar,
}
)
@@ -76,37 +89,17 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.figure.plot(x_name="samx", y_name="samy", z_name="bpm4i", color_map_z="cividis")
self.figure.motor_map("samx", "samy")
self.figure.image("eiger", color_map="viridis", vrange=(0, 100))
self.figure.plot(
x_name="samx", y_name="samy", z_name="bpm4i", color_map_z="magma", new=True
)
self.figure.add_plot(x_name="samx", y_name="samy", z_name="bpm4i", color_map_z="magma")
self.figure.change_layout(2, 2)
self.w1 = self.figure[0, 0]
self.w2 = self.figure[0, 1]
self.w3 = self.figure[1, 0]
self.w4 = self.figure[1, 1]
# Plot Customisation
self.w1.set_title("Waveform 1")
self.w1.set_x_label("Motor Position (samx)")
self.w1.set_y_label("Intensity A.U.")
# Image Customisation
self.w3.set_title("Eiger Image")
self.w3.set_x_label("X")
self.w3.set_y_label("Y")
# Configs to try to pass
self.w1_c = self.w1._config_dict
self.w2_c = self.w2._config_dict
self.w3_c = self.w3._config_dict
# curves for w1
self.c1 = self.w1.get_config()
self.fig_c = self.figure._config_dict
def _init_dock(self):
self.d0 = self.dock.add_dock(name="dock_0")
@@ -122,9 +115,8 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
self.fig2 = self.d2.add_widget("BECFigure", row=0, col=0)
self.plt = self.fig2.plot(x_name="samx", y_name="bpm3a")
self.plt.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1)
self.fig2.plot(x_name="samx", y_name="bpm4i")
self.bar = self.d2.add_widget("SpiralProgressBar", row=0, col=1)
self.bar.set_diameter(200)
self.dock.save_state()

View File

@@ -1,17 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
"""PySide6 port of the Qt Designer taskmenuextension example from Qt v6.x"""
import sys
from bec_ipython_client.main import BECIPythonClient
from qtpy.QtWidgets import QApplication
from tictactoe import TicTacToe
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = TicTacToe()
window.state = "-X-XO----"
window.show()
sys.exit(app.exec())

View File

@@ -1,12 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QPyDesignerCustomWidgetCollection
from tictactoe import TicTacToe
from tictactoeplugin import TicTacToePlugin
# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin
if __name__ == "__main__": # pragma: no cover
QPyDesignerCustomWidgetCollection.addCustomWidget(TicTacToePlugin())

View File

@@ -1,4 +0,0 @@
{
"files": ["tictactoe.py", "main.py", "registertictactoe.py", "tictactoeplugin.py",
"tictactoetaskmenu.py"]
}

View File

@@ -1,135 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtCore import Property, QPoint, QRect, QSize, Qt, Slot
from qtpy.QtGui import QPainter, QPen
from qtpy.QtWidgets import QWidget
EMPTY = "-"
CROSS = "X"
NOUGHT = "O"
DEFAULT_STATE = "---------"
class TicTacToe(QWidget): # pragma: no cover
def __init__(self, parent=None):
super().__init__(parent)
self._state = DEFAULT_STATE
self._turn_number = 0
def minimumSizeHint(self):
return QSize(200, 200)
def sizeHint(self):
return QSize(200, 200)
def setState(self, new_state):
self._turn_number = 0
self._state = DEFAULT_STATE
for position in range(min(9, len(new_state))):
mark = new_state[position]
if mark == CROSS or mark == NOUGHT:
self._turn_number += 1
self._change_state_at(position, mark)
position += 1
self.update()
def state(self):
return self._state
@Slot()
def clear_board(self):
self._state = DEFAULT_STATE
self._turn_number = 0
self.update()
def _change_state_at(self, pos, new_state):
self._state = self._state[:pos] + new_state + self._state[pos + 1 :]
def mousePressEvent(self, event):
if self._turn_number == 9:
self.clear_board()
return
for position in range(9):
cell = self._cell_rect(position)
if cell.contains(event.position().toPoint()):
if self._state[position] == EMPTY:
new_state = CROSS if self._turn_number % 2 == 0 else NOUGHT
self._change_state_at(position, new_state)
self._turn_number += 1
self.update()
def paintEvent(self, event):
with QPainter(self) as painter:
painter.setRenderHint(QPainter.Antialiasing)
painter.setPen(QPen(Qt.darkGreen, 1))
painter.drawLine(self._cell_width(), 0, self._cell_width(), self.height())
painter.drawLine(2 * self._cell_width(), 0, 2 * self._cell_width(), self.height())
painter.drawLine(0, self._cell_height(), self.width(), self._cell_height())
painter.drawLine(0, 2 * self._cell_height(), self.width(), 2 * self._cell_height())
painter.setPen(QPen(Qt.darkBlue, 2))
for position in range(9):
cell = self._cell_rect(position)
if self._state[position] == CROSS:
painter.drawLine(cell.topLeft(), cell.bottomRight())
painter.drawLine(cell.topRight(), cell.bottomLeft())
elif self._state[position] == NOUGHT:
painter.drawEllipse(cell)
painter.setPen(QPen(Qt.yellow, 3))
for position in range(0, 8, 3):
if (
self._state[position] != EMPTY
and self._state[position + 1] == self._state[position]
and self._state[position + 2] == self._state[position]
):
y = self._cell_rect(position).center().y()
painter.drawLine(0, y, self.width(), y)
self._turn_number = 9
for position in range(3):
if (
self._state[position] != EMPTY
and self._state[position + 3] == self._state[position]
and self._state[position + 6] == self._state[position]
):
x = self._cell_rect(position).center().x()
painter.drawLine(x, 0, x, self.height())
self._turn_number = 9
if (
self._state[0] != EMPTY
and self._state[4] == self._state[0]
and self._state[8] == self._state[0]
):
painter.drawLine(0, 0, self.width(), self.height())
self._turn_number = 9
if (
self._state[2] != EMPTY
and self._state[4] == self._state[2]
and self._state[6] == self._state[2]
):
painter.drawLine(0, self.height(), self.width(), 0)
self._turn_number = 9
def _cell_rect(self, position):
h_margin = self.width() / 30
v_margin = self.height() / 30
row = int(position / 3)
column = position - 3 * row
pos = QPoint(column * self._cell_width() + h_margin, row * self._cell_height() + v_margin)
size = QSize(self._cell_width() - 2 * h_margin, self._cell_height() - 2 * v_margin)
return QRect(pos, size)
def _cell_width(self):
return self.width() / 3
def _cell_height(self):
return self.height() / 3
state = Property(str, state, setState)

View File

@@ -1,68 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from tictactoe import TicTacToe
from tictactoetaskmenu import TicTacToeTaskMenuFactory
DOM_XML = """
<ui language='c++'>
<widget class='TicTacToe' name='ticTacToe'>
<property name='geometry'>
<rect>
<x>0</x>
<y>0</y>
<width>200</width>
<height>200</height>
</rect>
</property>
<property name='state'>
<string>-X-XO----</string>
</property>
</widget>
</ui>
"""
class TicTacToePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = TicTacToe(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return QIcon()
def includeFile(self):
return "tictactoe"
def initialize(self, form_editor):
self._form_editor = form_editor
manager = form_editor.extensionManager()
iid = TicTacToeTaskMenuFactory.task_menu_iid()
manager.registerExtensions(TicTacToeTaskMenuFactory(manager), iid)
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "TicTacToe"
def toolTip(self):
return "Tic Tac Toe Example, demonstrating class QDesignerTaskMenuExtension (Python)"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,67 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtCore import Slot
from qtpy.QtDesigner import QExtensionFactory, QPyDesignerTaskMenuExtension
from qtpy.QtGui import QAction
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
from tictactoe import TicTacToe
class TicTacToeDialog(QDialog): # pragma: no cover
def __init__(self, parent):
super().__init__(parent)
layout = QVBoxLayout(self)
self._ticTacToe = TicTacToe(self)
layout.addWidget(self._ticTacToe)
button_box = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Reset
)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
reset_button = button_box.button(QDialogButtonBox.Reset)
reset_button.clicked.connect(self._ticTacToe.clear_board)
layout.addWidget(button_box)
def set_state(self, new_state):
self._ticTacToe.setState(new_state)
def state(self):
return self._ticTacToe.state
class TicTacToeTaskMenu(QPyDesignerTaskMenuExtension):
def __init__(self, ticTacToe, parent):
super().__init__(parent)
self._ticTacToe = ticTacToe
self._edit_state_action = QAction("Edit State...", None)
self._edit_state_action.triggered.connect(self._edit_state)
def taskActions(self):
return [self._edit_state_action]
def preferredEditAction(self):
return self._edit_state_action
@Slot()
def _edit_state(self):
dialog = TicTacToeDialog(self._ticTacToe)
dialog.set_state(self._ticTacToe.state)
if dialog.exec() == QDialog.Accepted:
self._ticTacToe.state = dialog.state()
class TicTacToeTaskMenuFactory(QExtensionFactory):
def __init__(self, extension_manager):
super().__init__(extension_manager)
@staticmethod
def task_menu_iid():
return "org.qt-project.Qt.Designer.TaskMenu"
def createExtension(self, object, iid, parent):
if iid != TicTacToeTaskMenuFactory.task_menu_iid():
return None
if object.__class__.__name__ != "TicTacToe":
return None
return TicTacToeTaskMenu(object, parent)

View File

@@ -1,5 +1,3 @@
from qtpy.QtWebEngineWidgets import QWebEngineView
from .bec_connector import BECConnector, ConnectionConfig
from .bec_dispatcher import BECDispatcher
from .bec_table import BECTable

View File

@@ -1,19 +1,14 @@
# pylint: disable = no-name-in-module,missing-module-docstring
from __future__ import annotations
import os
import time
import uuid
from typing import Optional
from typing import Optional, Type
import yaml
from bec_lib.utils.import_utils import lazy_import_from
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
from qtpy.QtCore import Slot as pyqtSlot
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
@@ -38,35 +33,10 @@ class ConnectionConfig(BaseModel):
return v
class WorkerSignals(QObject):
progress = Signal(dict)
completed = Signal()
class Worker(QRunnable):
"""
Worker class to run a function in a separate thread.
"""
def __init__(self, func, *args, **kwargs):
super().__init__()
self.signals = WorkerSignals()
self.func = func
self.args = args
self.kwargs = kwargs
def run(self):
"""
Run the specified function in the thread.
"""
self.func(*self.args, **self.kwargs)
self.signals.completed.emit()
class BECConnector:
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
USER_ACCESS = ["_config_dict", "_get_all_rpc"]
USER_ACCESS = ["config_dict", "get_all_rpc"]
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
# BEC related connections
@@ -93,60 +63,23 @@ class BECConnector:
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
self._thread_pool = QThreadPool.globalInstance()
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
"""
Submit a task to run in a separate thread. The task will run the specified
function with the provided arguments and emit the completed signal when done.
Use this method if you want to wait for a task to complete without blocking the
main thread.
Args:
fn: Function to run in a separate thread.
*args: Arguments for the function.
on_complete: Slot to run when the task is complete.
**kwargs: Keyword arguments for the function.
Returns:
worker: The worker object that will run the task.
Examples:
>>> def my_function(a, b):
>>> print(a + b)
>>> self.submit_task(my_function, 1, 2)
>>> def my_function(a, b):
>>> print(a + b)
>>> def on_complete():
>>> print("Task complete")
>>> self.submit_task(my_function, 1, 2, on_complete=on_complete)
"""
worker = Worker(fn, *args, **kwargs)
if on_complete:
worker.signals.completed.connect(on_complete)
self._thread_pool.start(worker)
return worker
def _get_all_rpc(self) -> dict:
def get_all_rpc(self) -> dict:
"""Get all registered RPC objects."""
all_connections = self.rpc_register.list_all_connections()
return dict(all_connections)
@property
def _rpc_id(self) -> str:
def rpc_id(self) -> str:
"""Get the RPC ID of the widget."""
return self.gui_id
@_rpc_id.setter
def _rpc_id(self, rpc_id: str) -> None:
@rpc_id.setter
def rpc_id(self, rpc_id: str) -> None:
"""Set the RPC ID of the widget."""
self.gui_id = rpc_id
@property
def _config_dict(self) -> dict:
def config_dict(self) -> dict:
"""
Get the configuration of the widget.
@@ -155,8 +88,8 @@ class BECConnector:
"""
return self.config.model_dump()
@_config_dict.setter
def _config_dict(self, config: BaseModel) -> None:
@config_dict.setter
def config_dict(self, config: BaseModel) -> None:
"""
Get the configuration of the widget.
@@ -165,60 +98,6 @@ class BECConnector:
"""
self.config = config
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
"""
Apply the configuration to the widget.
Args:
config(dict): Configuration settings.
generate_new_id(bool): If True, generate a new GUI ID for the widget.
"""
self.config = ConnectionConfig(**config)
if generate_new_id is True:
gui_id = str(uuid.uuid4())
self.rpc_register.remove_rpc(self)
self.set_gui_id(gui_id)
self.rpc_register.add_rpc(self)
else:
self.gui_id = self.config.gui_id
def load_config(self, path: str | None = None, gui: bool = False):
"""
Load the configuration of the widget from YAML.
Args:
path(str): Path to the configuration file for non-GUI dialog mode.
gui(bool): If True, use the GUI dialog to load the configuration file.
"""
if gui is True:
config = load_yaml_gui(self)
else:
config = load_yaml(path)
if config is not None:
if config.get("widget_class") != self.__class__.__name__:
raise ValueError(
f"Configuration file is not for {self.__class__.__name__}. Got configuration for {config.get('widget_class')}."
)
self.apply_config(config)
def save_config(self, path: str | None = None, gui: bool = False):
"""
Save the configuration of the widget to YAML.
Args:
path(str): Path to save the configuration file for non-GUI dialog mode.
gui(bool): If True, use the GUI dialog to save the configuration file.
"""
if gui is True:
save_yaml_gui(self, self._config_dict)
else:
if path is None:
path = os.getcwd()
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
save_yaml(file_path, self._config_dict)
@pyqtSlot(str)
def set_gui_id(self, gui_id: str) -> None:
"""
@@ -286,7 +165,6 @@ class BECConnector:
all_connections = self.rpc_register.list_all_connections()
if len(all_connections) == 0:
print("No more connections. Shutting down GUI BEC client.")
self.bec_dispatcher.disconnect_all()
self.client.shutdown()
# def closeEvent(self, event):

View File

@@ -1,138 +0,0 @@
import importlib.metadata
import json
import os
import site
import sys
import sysconfig
from pathlib import Path
from qtpy import PYSIDE6
if PYSIDE6:
from PySide6.scripts.pyside_tool import (
_extend_path_var,
init_virtual_env,
qt_tool_wrapper,
is_pyenv_python,
is_virtual_env,
ui_tool_binary,
)
import bec_widgets
def list_editable_packages() -> set[str]:
"""
List all editable packages in the environment.
Returns:
set: A set of paths to editable packages.
"""
editable_packages = set()
# Get site-packages directories
site_packages = site.getsitepackages()
if hasattr(site, "getusersitepackages"):
site_packages.append(site.getusersitepackages())
for dist in importlib.metadata.distributions():
location = dist.locate_file("").resolve()
is_editable = all(not str(location).startswith(site_pkg) for site_pkg in site_packages)
if is_editable:
editable_packages.add(str(location))
for packages in site_packages:
# all dist-info directories in site-packages that contain a direct_url.json file
dist_info_dirs = Path(packages).rglob("*.dist-info")
for dist_info_dir in dist_info_dirs:
direct_url = dist_info_dir / "direct_url.json"
if not direct_url.exists():
continue
# load the json file and get the path to the package
with open(direct_url, "r", encoding="utf-8") as f:
data = json.load(f)
path = data.get("url", "")
if path.startswith("file://"):
path = path[7:]
editable_packages.add(path)
return editable_packages
def patch_designer(): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
init_virtual_env()
major_version = sys.version_info[0]
minor_version = sys.version_info[1]
os.environ["PY_MAJOR_VERSION"] = str(major_version)
os.environ["PY_MINOR_VERSION"] = str(minor_version)
if sys.platform == "win32":
if is_virtual_env():
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
else:
if sys.platform == "linux":
suffix = f"{sys.abiflags}.so"
env_var = "LD_PRELOAD"
elif sys.platform == "darwin":
suffix = ".dylib"
env_var = "DYLD_INSERT_LIBRARIES"
else:
raise RuntimeError(f"Unsupported platform: {sys.platform}")
version = f"{major_version}.{minor_version}"
library_name = f"libpython{version}{suffix}"
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
os.environ[env_var] = lib_path
if is_pyenv_python() or is_virtual_env():
# append all editable packages to the PYTHONPATH
editable_packages = list_editable_packages()
for pckg in editable_packages:
_extend_path_var("PYTHONPATH", pckg, True)
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
def find_plugin_paths(base_path: Path):
"""
Recursively find all directories containing a .pyproject file.
"""
plugin_paths = []
for path in base_path.rglob("*.pyproject"):
plugin_paths.append(str(path.parent))
return plugin_paths
def set_plugin_environment_variable(plugin_paths):
"""
Set the PYSIDE_DESIGNER_PLUGINS environment variable with the given plugin paths.
"""
current_paths = os.environ.get("PYSIDE_DESIGNER_PLUGINS", "")
if current_paths:
current_paths = current_paths.split(os.pathsep)
else:
current_paths = []
current_paths.extend(plugin_paths)
os.environ["PYSIDE_DESIGNER_PLUGINS"] = os.pathsep.join(current_paths)
# Patch the designer function
def main(): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Exiting...")
return
base_dir = Path(os.path.dirname(bec_widgets.__file__)).resolve()
plugin_paths = find_plugin_paths(base_dir)
set_plugin_environment_variable(plugin_paths)
patch_designer()
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import argparse
import collections
from collections.abc import Callable
from typing import TYPE_CHECKING, Union
@@ -8,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 PYQT5, PYQT6, PYSIDE2, PYSIDE6, QCoreApplication, QObject
from qtpy.QtCore import QCoreApplication, QObject
from qtpy.QtCore import Signal as pyqtSignal
if TYPE_CHECKING:
@@ -65,11 +66,6 @@ class QtRedisConnector(RedisConnector):
cb(msg.content, msg.metadata)
class BECClientWithoutLoggerInit(BECClient):
def _initialize_logger(self):
return
class BECDispatcher:
"""Utility class to keep track of slots connected to a particular redis connector"""
@@ -83,7 +79,7 @@ class BECDispatcher:
cls._initialized = False
return cls._instance
def __init__(self, client=None, config: str | ServiceConfig = None):
def __init__(self, client=None, config: str = None):
if self._initialized:
return
@@ -95,16 +91,13 @@ class BECDispatcher:
if self.client is None:
if config is not None:
if not isinstance(config, ServiceConfig):
# config is supposed to be a path
config = ServiceConfig(config)
self.client = BECClientWithoutLoggerInit(
config=config, connector_cls=QtRedisConnector
host, port = config.split(":")
redis_config = {"host": host, "port": port}
self.client = BECClient(
config=ServiceConfig(redis=redis_config), connector_cls=QtRedisConnector
) # , forced=True)
else:
self.client = BECClientWithoutLoggerInit(
connector_cls=QtRedisConnector
) # , forced=True)
self.client = BECClient(connector_cls=QtRedisConnector) # , forced=True)
else:
if self.client.started:
# have to reinitialize client to use proper connector
@@ -123,16 +116,6 @@ class BECDispatcher:
cls._instance = None
cls._initialized = False
if not cls.qapp:
return
# shutdown QCoreApp if it exists
if PYQT5 or PYQT6:
cls.qapp.exit()
elif PYSIDE2 or PYSIDE6:
cls.qapp.shutdown()
cls.qapp = None
def connect_slot(
self, slot: Callable, topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]]
) -> None:

View File

@@ -67,44 +67,6 @@ class Colors:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
return colors
@staticmethod
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
"""
Convert HEX color to RGBA.
Args:
hex_color(str): HEX color string.
alpha(int): Alpha value (0-255). Default is 255 (opaque).
Returns:
tuple: RGBA color tuple (r, g, b, a).
"""
hex_color = hex_color.lstrip("#")
if len(hex_color) == 6:
r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
elif len(hex_color) == 8:
r, g, b, a = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4, 6))
return (r, g, b, a)
else:
raise ValueError("HEX color must be 6 or 8 characters long.")
return (r, g, b, alpha)
@staticmethod
def rgba_to_hex(r: int, g: int, b: int, a: int = 255) -> str:
"""
Convert RGBA color to HEX.
Args:
r(int): Red value (0-255).
g(int): Green value (0-255).
b(int): Blue value (0-255).
a(int): Alpha value (0-255). Default is 255 (opaque).
Returns:
hec_color(str): HEX color string.
"""
return "#{:02X}{:02X}{:02X}{:02X}".format(r, g, b, a)
@staticmethod
def validate_color(color: tuple | str) -> tuple | str:
"""

View File

@@ -1,148 +0,0 @@
import inspect
import os
import re
from qtpy.QtCore import QObject
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock"]
class DesignerPluginInfo:
def __init__(self, plugin_class):
self.plugin_class = plugin_class
self.plugin_name_pascal = plugin_class.__name__
self.plugin_name_snake = self.pascal_to_snake(self.plugin_name_pascal)
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
plugin_module = (
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
)
self.plugin_import = f"from {plugin_module} import {self.plugin_name_pascal}Plugin"
# first sentence / line of the docstring is used as tooltip
self.plugin_tooltip = (
plugin_class.__doc__.split("\n")[0].strip().replace('"', "'")
if plugin_class.__doc__
else self.plugin_name_pascal
)
self.base_path = os.path.dirname(inspect.getfile(plugin_class))
@staticmethod
def pascal_to_snake(name: str) -> str:
"""
Convert PascalCase to snake_case.
Args:
name (str): The name to be converted.
Returns:
str: The converted name.
"""
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
return s2.lower()
class DesignerPluginGenerator:
def __init__(self, widget: type):
self._excluded = False
self.widget = widget
self.info = DesignerPluginInfo(widget)
if widget.__name__ in EXCLUDED_PLUGINS:
self._excluded = True
return
self.templates = {}
self.template_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "plugin_templates"
)
def run(self):
if self._excluded:
print(f"Plugin {self.widget.__name__} is excluded from generation.")
return
self._check_class_validity()
self._load_templates()
self._write_templates()
def _check_class_validity(self):
# Check if the widget is a QWidget subclass
if not issubclass(self.widget, QObject):
return
# Check if the widget class has parent as the first argument. This is a strict requirement of Qt!
signature = list(inspect.signature(self.widget.__init__).parameters.values())
if len(signature) == 1 or signature[1].name != "parent":
raise ValueError(
f"Widget class {self.widget.__name__} must have parent as the first argument."
)
base_cls = [val for val in self.widget.__bases__ if issubclass(val, QObject)]
if not base_cls:
raise ValueError(
f"Widget class {self.widget.__name__} must inherit from a QObject subclass."
)
# Check if the widget class calls the super constructor with parent argument
init_source = inspect.getsource(self.widget.__init__)
cls_init_found = (
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
)
super_init_found = (
bool(
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
)
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
super_init_found = (
bool(init_source.find("super().__init__(parent=parent") > 0)
or bool(init_source.find("super().__init__(parent,") > 0)
or bool(init_source.find("super().__init__(parent)") > 0)
)
if not cls_init_found and not super_init_found:
raise ValueError(
f"Widget class {self.widget.__name__} must call the super constructor with parent."
)
def _write_templates(self):
self._write_register()
self._write_plugin()
self._write_pyproject()
def _write_register(self):
file_path = os.path.join(self.info.base_path, f"register_{self.info.plugin_name_snake}.py")
with open(file_path, "w", encoding="utf-8") as f:
f.write(self.templates["register"].format(**self.info.__dict__))
def _write_plugin(self):
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}_plugin.py")
with open(file_path, "w", encoding="utf-8") as f:
f.write(self.templates["plugin"].format(**self.info.__dict__))
def _write_pyproject(self):
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}.pyproject")
out = {"files": [f"{self.info.plugin_class.__module__.split('.')[-1]}.py"]}
with open(file_path, "w", encoding="utf-8") as f:
f.write(str(out))
def _load_templates(self):
for file in os.listdir(self.template_path):
if not file.endswith(".template"):
continue
with open(os.path.join(self.template_path, file), "r", encoding="utf-8") as f:
self.templates[file.split(".")[0]] = f.read()
if __name__ == "__main__": # pragma: no cover
# from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.dock import BECDockArea
generator = DesignerPluginGenerator(BECDockArea)
generator.run()

View File

@@ -1,54 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
{widget_import}
DOM_XML = """
<ui language='c++'>
<widget class='{plugin_name_pascal}' name='{plugin_name_snake}'>
</widget>
</ui>
"""
class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = {plugin_name_pascal}(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return QIcon()
def includeFile(self):
return "{plugin_name_snake}"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "{plugin_name_pascal}"
def toolTip(self):
return "{plugin_tooltip}"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,15 +0,0 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
{plugin_import}
QPyDesignerCustomWidgetCollection.addCustomWidget({plugin_name_pascal}Plugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,10 +1,6 @@
import importlib
import inspect
import os
from typing import Literal
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils import BECConnector
@@ -42,47 +38,3 @@ def get_plugin_widgets() -> dict[str, BECConnector]:
def _filter_plugins(obj):
return inspect.isclass(obj) and issubclass(obj, BECConnector)
def get_rpc_classes(
repo_name: str,
) -> dict[Literal["connector_classes", "top_level_classes"], list[type]]:
"""
Get all RPC-enabled classes in the specified repository.
Args:
repo_name(str): The name of the repository.
Returns:
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
connector_classes = []
top_level_classes = []
anchor_module = importlib.import_module(f"{repo_name}.widgets")
directory = os.path.dirname(anchor_module.__file__)
for root, _, files in sorted(os.walk(directory)):
for file in files:
if not file.endswith(".py") or file.startswith("__"):
continue
path = os.path.join(root, file)
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
if len(subs) == 1 and not subs[0]:
module_name = file.split(".")[0]
else:
module_name = ".".join(subs + [file.split(".")[0]])
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
for name in dir(module):
obj = getattr(module, name)
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
continue
if isinstance(obj, type) and issubclass(obj, BECConnector):
connector_classes.append(obj)
if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
top_level_classes.append(obj)
return {"connector_classes": connector_classes, "top_level_classes": top_level_classes}

View File

@@ -1,30 +1,6 @@
from qtpy import PYQT6, PYSIDE6, QT_VERSION
from qtpy import QT_VERSION
from qtpy.QtCore import QFile, QIODevice
if PYSIDE6:
from PySide6.QtUiTools import QUiLoader
from bec_widgets.utils.plugin_utils import get_rpc_classes
from bec_widgets.widgets.buttons.color_button.color_button import ColorButton
class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance):
super().__init__(baseinstance)
widgets = get_rpc_classes("bec_widgets").get("top_level_classes", [])
widgets.append(ColorButton)
self.custom_widgets = {widget.__name__: widget for widget in widgets}
self.baseinstance = baseinstance
def createWidget(self, class_name, parent=None, name=""):
if class_name in self.custom_widgets:
widget = self.custom_widgets[class_name](parent)
widget.setObjectName(name)
return widget
return super().createWidget(class_name, parent, name)
class UILoader:
"""Universal UI loader for PyQt5, PyQt6, PySide2, and PySide6."""
@@ -38,14 +14,14 @@ class UILoader:
self.loader = uic.loadUi
elif QT_VERSION.startswith("6"):
# PyQt6 or PySide6
if PYSIDE6:
try:
from PySide6.QtUiTools import QUiLoader
self.loader = self.load_ui_pyside6
elif PYQT6:
except ImportError:
from PyQt6.uic import loadUi
self.loader = loadUi
else:
raise ImportError("No compatible Qt bindings found.")
def load_ui_pyside6(self, ui_file, parent=None):
"""
@@ -57,8 +33,9 @@ class UILoader:
Returns:
QWidget: The loaded widget.
"""
from PySide6.QtUiTools import QUiLoader
loader = CustomUiLoader(parent)
loader = QUiLoader(parent)
file = QFile(ui_file)
if not file.open(QIODevice.ReadOnly):
raise IOError(f"Cannot open file: {ui_file}")

View File

@@ -44,11 +44,8 @@ class ComboBoxHandler(WidgetHandler):
def get_value(self, widget: QComboBox) -> int:
return widget.currentIndex()
def set_value(self, widget: QComboBox, value: int | str) -> None:
if isinstance(value, str):
value = widget.findText(value)
if isinstance(value, int):
widget.setCurrentIndex(value)
def set_value(self, widget: QComboBox, value: int) -> None:
widget.setCurrentIndex(value)
class TableWidgetHandler(WidgetHandler):
@@ -122,7 +119,7 @@ class WidgetIO:
widget: Widget instance.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = WidgetIO._find_handler(widget)
handler_class = WidgetIO._handlers.get(type(widget))
if handler_class:
return handler_class().get_value(widget) # Instantiate the handler
if not ignore_errors:
@@ -139,48 +136,12 @@ class WidgetIO:
value: Value to set.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = WidgetIO._find_handler(widget)
handler_class = WidgetIO._handlers.get(type(widget))
if handler_class:
handler_class().set_value(widget, value) # Instantiate the handler
elif not ignore_errors:
raise ValueError(f"No handler for widget type: {type(widget)}")
@staticmethod
def check_and_adjust_limits(spin_box: QDoubleSpinBox, number: float):
"""
Check if the new limits are within the current limits, if not adjust the limits.
Args:
number(float): The new value to check against the limits.
"""
min_value = spin_box.minimum()
max_value = spin_box.maximum()
# Calculate the new limits
new_limit = number + 5 * number
if number < min_value:
spin_box.setMinimum(new_limit)
elif number > max_value:
spin_box.setMaximum(new_limit)
@staticmethod
def _find_handler(widget):
"""
Find the appropriate handler for the widget by checking its base classes.
Args:
widget: Widget instance.
Returns:
handler_class: The handler class if found, otherwise None.
"""
for base in type(widget).__mro__:
if base in WidgetIO._handlers:
return WidgetIO._handlers[base]
return None
################## for exporting and importing widget hierarchies ##################

View File

@@ -6,7 +6,7 @@ import yaml
from qtpy.QtWidgets import QFileDialog
def load_yaml_gui(instance) -> Union[dict, None]:
def load_yaml(instance) -> Union[dict, None]:
"""
Load YAML file from disk.
@@ -20,25 +20,12 @@ def load_yaml_gui(instance) -> Union[dict, None]:
file_path, _ = QFileDialog.getOpenFileName(
instance, "Load Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
)
config = load_yaml(file_path)
return config
def load_yaml(file_path: str) -> Union[dict, None]:
"""
Load YAML file from disk.
Args:
file_path(str): Path to the YAML file.
Returns:
dict: Configuration data loaded from the YAML file.
"""
if not file_path:
return None
try:
with open(file_path, "r") as file:
config = yaml.load(file, Loader=yaml.FullLoader)
config = yaml.safe_load(file)
return config
except FileNotFoundError:
@@ -51,7 +38,7 @@ def load_yaml(file_path: str) -> Union[dict, None]:
print(f"An error occurred while loading the settings from {file_path}: {e}")
def save_yaml_gui(instance, config: dict) -> None:
def save_yaml(instance, config: dict) -> None:
"""
Save YAML file to disk.
@@ -64,17 +51,6 @@ def save_yaml_gui(instance, config: dict) -> None:
instance, "Save Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
)
save_yaml(file_path, config)
def save_yaml(file_path: str, config: dict) -> None:
"""
Save YAML file to disk.
Args:
file_path(str): Path to the YAML file.
config(dict): Configuration data to be saved.
"""
if not file_path:
return
try:

View File

@@ -1 +1,5 @@
from .buttons import StopButton
from .dock import BECDock, BECDockArea
from .figure import BECFigure, FigureConfig
from .scan_control import ScanControl
from .spiral_progress_bar import SpiralProgressBar

View File

@@ -1,111 +0,0 @@
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import Qt, Slot
from qtpy.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
class BECQueue(BECConnector, QTableWidget):
"""
Widget to display the BEC queue.
"""
def __init__(
self,
parent: QWidget | None = None,
client=None,
config: ConnectionConfig = None,
gui_id: str = None,
):
super().__init__(client, config, gui_id)
QTableWidget.__init__(self, parent=parent)
self.setColumnCount(3)
self.setHorizontalHeaderLabels(["Scan Number", "Type", "Status"])
header = self.horizontalHeader()
header.setSectionResizeMode(QHeaderView.Stretch)
self.bec_dispatcher.connect_slot(self.update_queue, MessageEndpoints.scan_queue_status())
self.reset_content()
@Slot(dict, dict)
def update_queue(self, content, _metadata):
"""
Update the queue table with the latest queue information.
Args:
content (dict): The queue content.
_metadata (dict): The metadata.
"""
# only show the primary queue for now
queue_info = content.get("queue", {}).get("primary", {}).get("info", [])
self.setRowCount(len(queue_info))
self.clearContents()
if not queue_info:
self.reset_content()
return
for index, item in enumerate(queue_info):
blocks = item.get("request_blocks", [])
scan_types = []
scan_numbers = []
status = item.get("status", "")
for request_block in blocks:
scan_type = request_block.get("content", {}).get("scan_type", "")
if scan_type:
scan_types.append(scan_type)
scan_number = request_block.get("scan_number", "")
if scan_number:
scan_numbers.append(str(scan_number))
if scan_types:
scan_types = ", ".join(scan_types)
if scan_numbers:
scan_numbers = ", ".join(scan_numbers)
self.set_row(index, scan_numbers, scan_types, status)
def format_item(self, content: str) -> QTableWidgetItem:
"""
Format the content of the table item.
Args:
content (str): The content to be formatted.
Returns:
QTableWidgetItem: The formatted item.
"""
item = QTableWidgetItem(content)
item.setTextAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
return item
def set_row(self, index: int, scan_number: str, scan_type: str, status: str):
"""
Set the row of the table.
Args:
index (int): The index of the row.
scan_number (str): The scan number.
scan_type (str): The scan type.
status (str): The status.
"""
self.setItem(index, 0, self.format_item(scan_number))
self.setItem(index, 1, self.format_item(scan_type))
self.setItem(index, 2, self.format_item(status))
def reset_content(self):
"""
Reset the content of the table.
"""
self.setRowCount(1)
self.set_row(0, "", "", "")
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = BECQueue()
widget.show()
sys.exit(app.exec_())

View File

@@ -1 +0,0 @@
{'files': ['bec_queue.py']}

View File

@@ -1,54 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
DOM_XML = """
<ui language='c++'>
<widget class='BECQueue' name='bec_queue'>
</widget>
</ui>
"""
class BECQueuePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECQueue(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return QIcon()
def includeFile(self):
return "bec_queue"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECQueue"
def toolTip(self):
return "Widget to display the BEC queue."
def whatsThis(self):
return self.toolTip()

View File

@@ -1,15 +0,0 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.bec_queue.bec_queue_plugin import BECQueuePlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECQueuePlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,356 +0,0 @@
"""This module contains the BECStatusBox widget, which displays the status of different BEC services in a collapsible tree widget.
The widget automatically updates the status of all running BEC services, and displays their status.
"""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING
import qdarktheme
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QTimer, Signal, Slot
from qtpy.QtWidgets import QTreeWidget, QTreeWidgetItem
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.widgets.bec_status_box.status_item import StatusItem
if TYPE_CHECKING:
from bec_lib.client import BECClient
# TODO : Put normal imports back when Pydantic gets faster
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
class BECStatusBoxConfig(ConnectionConfig):
pass
class BECServiceInfoContainer(BaseModel):
"""Container to store information about the BEC services."""
service_name: str
status: BECStatus | str = Field(
default="NOTCONNECTED",
description="The status of the service. Can be any of the BECStatus names, or NOTCONNECTED.",
)
info: dict
metrics: dict | None
model_config: dict = {"validate_assignment": True}
@field_validator("status")
@classmethod
def validate_status(cls, v):
"""Validate input for status. Accept BECStatus and NOTCONNECTED.
Args:
v (BECStatus | str): The input value.
Returns:
str: The validated status.
"""
if v in list(BECStatus.__members__.values()):
return v.name
if v in list(BECStatus.__members__.keys()) or v == "NOTCONNECTED":
return v
raise ValueError(
f"Status must be one of {BECStatus.__members__.values()} or 'NOTCONNECTED'. Input {v}"
)
class BECServiceStatusMixin(QObject):
"""A mixin class to update the service status, and metrics.
It emits a signal 'services_update' when the service status is updated.
Args:
client (BECClient): The client object to connect to the BEC server.
"""
services_update = Signal(dict, dict)
def __init__(self, client: BECClient):
super().__init__()
self.client = client
self._service_update_timer = QTimer()
self._service_update_timer.timeout.connect(self._get_service_status)
self._service_update_timer.start(1000)
def _get_service_status(self):
"""Pull latest service and metrics updates from REDIS for all services, and emit both via 'services_update' signal."""
# pylint: disable=protected-access
self.client._update_existing_services()
self.services_update.emit(self.client._services_info, self.client._services_metric)
class BECStatusBox(BECConnector, QTreeWidget):
"""A widget to display the status of different BEC services.
This widget automatically updates the status of all running BEC services, and displays their status.
Information about the individual services is collapsible, and double clicking on
the individual service will display the metrics about the service.
Args:
parent Optional : The parent widget for the BECStatusBox. Defaults to None.
service_name Optional(str): The name of the top service label. Defaults to "BEC Server".
client Optional(BECClient): The client object to connect to the BEC server. Defaults to None
config Optional(BECStatusBoxConfig | dict): The configuration for the status box. Defaults to None.
gui_id Optional(str): The unique id for the widget. Defaults to None.
"""
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
service_update = Signal(dict)
bec_core_state = Signal(str)
def __init__(
self,
parent=None,
service_name: str = "BEC Server",
client: BECClient = None,
config: BECStatusBoxConfig | dict = None,
bec_service_status_mixin: BECServiceStatusMixin = None,
gui_id: str = None,
):
if config is None:
config = BECStatusBoxConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = BECStatusBoxConfig(**config)
super().__init__(client=client, config=config, gui_id=gui_id)
QTreeWidget.__init__(self, parent=parent)
self.service_name = service_name
self.config = config
self.bec_service_info_container = {}
self.tree_items = {}
self.tree_top_item = None
if not bec_service_status_mixin:
bec_service_status_mixin = BECServiceStatusMixin(client=self.client)
self.bec_service_status = bec_service_status_mixin
self.init_ui()
self.bec_service_status.services_update.connect(self.update_service_status)
self.bec_core_state.connect(self.update_top_item_status)
self.itemDoubleClicked.connect(self.on_tree_item_double_clicked)
def init_ui(self) -> None:
"""Initialize the UI for the status box, and add QTreeWidget as the basis for the status box."""
self.init_ui_tree_widget()
top_label = self._create_status_widget(self.service_name, status=BECStatus.IDLE)
self.tree_top_item = QTreeWidgetItem()
self.tree_top_item.setExpanded(True)
self.tree_top_item.setDisabled(True)
self.addTopLevelItem(self.tree_top_item)
self.setItemWidget(self.tree_top_item, 0, top_label)
self.service_update.connect(top_label.update_config)
def _create_status_widget(
self, service_name: str, status=BECStatus, info: dict = None, metrics: dict = None
) -> StatusItem:
"""Creates a StatusItem (QWidget) for the given service, and stores all relevant
information about the service in the bec_service_info_container.
Args:
service_name (str): The name of the service.
status (BECStatus): The status of the service.
info Optional(dict): The information about the service. Default is {}
metric Optional(dict): Metrics for the respective service. Default is None
Returns:
StatusItem: The status item widget.
"""
if info is None:
info = {}
self._update_bec_service_container(service_name, status, info, metrics)
item = StatusItem(
parent=self,
config={
"service_name": service_name,
"status": status.name,
"info": info,
"metrics": metrics,
},
)
return item
@Slot(str)
def update_top_item_status(self, status: BECStatus) -> None:
"""Method to update the status of the top item in the tree widget.
Gets the status from the Signal 'bec_core_state' and updates the StatusItem via the signal 'service_update'.
Args:
status (BECStatus): The state of the core services.
"""
self.bec_service_info_container[self.service_name].status = status
self.service_update.emit(self.bec_service_info_container[self.service_name].model_dump())
def _update_bec_service_container(
self, service_name: str, status: BECStatus, info: dict, metrics: dict = None
) -> None:
"""Update the bec_service_info_container with the newest status and metrics for the BEC service.
If information about the service already exists, it will create a new entry.
Args:
service_name (str): The name of the service.
service_info (StatusMessage): A class containing the service status.
service_metric (ServiceMetricMessage): A class containing the service metrics.
"""
container = self.bec_service_info_container.get(service_name, None)
if container:
container.status = status
container.info = info
container.metrics = metrics
return
service_info_item = BECServiceInfoContainer(
service_name=service_name, status=status, info=info, metrics=metrics
)
self.bec_service_info_container.update({service_name: service_info_item})
@Slot(dict, dict)
def update_service_status(self, services_info: dict, services_metric: dict) -> None:
"""Callback function services_metric from BECServiceStatusMixin.
It updates the status of all services.
Args:
services_info (dict): A dictionary containing the service status for all running BEC services.
services_metric (dict): A dictionary containing the service metrics for all running BEC services.
"""
checked = []
services_info = self.update_core_services(services_info, services_metric)
checked.extend(self.CORE_SERVICES)
for service_name, msg in sorted(services_info.items()):
checked.append(service_name)
metric_msg = services_metric.get(service_name, None)
metrics = metric_msg.metrics if metric_msg else None
if service_name in self.tree_items:
self._update_bec_service_container(
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
)
self.service_update.emit(self.bec_service_info_container[service_name].model_dump())
continue
item_widget = self._create_status_widget(
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
)
item = QTreeWidgetItem()
item.setDisabled(True)
self.service_update.connect(item_widget.update_config)
self.tree_top_item.addChild(item)
self.setItemWidget(item, 0, item_widget)
self.tree_items.update({service_name: (item, item_widget)})
self.check_redundant_tree_items(checked)
def update_core_services(self, services_info: dict, services_metric: dict) -> dict:
"""Method to process status and metrics updates of core services (stored in CORE_SERVICES).
If a core services is not connected, it should not be removed from the status widget
Args:
services_info (dict): A dictionary containing the service status of different services.
services_metric (dict): A dictionary containing the service metrics of different services.
Returns:
dict: The services_info dictionary after removing the info updates related to the CORE_SERVICES
"""
bec_core_state = "RUNNING"
for service_name in sorted(self.CORE_SERVICES):
metric_msg = services_metric.get(service_name, None)
metrics = metric_msg.metrics if metric_msg else None
if service_name not in services_info:
self.bec_service_info_container[service_name].status = "NOTCONNECTED"
bec_core_state = "ERROR"
else:
msg = services_info.pop(service_name)
self._update_bec_service_container(
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
)
bec_core_state = (
"RUNNING" if (msg.status.value > 1 and bec_core_state == "RUNNING") else "ERROR"
)
if service_name in self.tree_items:
self.service_update.emit(self.bec_service_info_container[service_name].model_dump())
continue
self.add_tree_item(service_name, msg.status, msg.info, metrics)
self.bec_core_state.emit(bec_core_state)
return services_info
def check_redundant_tree_items(self, checked: list) -> None:
"""Utility method to check and remove redundant objects from the BECStatusBox.
Args:
checked (list): A list of services that are currently running.
"""
to_be_deleted = [key for key in self.tree_items if key not in checked]
for key in to_be_deleted:
item, _ = self.tree_items.pop(key)
self.tree_top_item.removeChild(item)
def add_tree_item(
self, service_name: str, status: BECStatus, info: dict = None, metrics: dict = None
) -> None:
"""Method to add a new QTreeWidgetItem together with a StatusItem to the tree widget.
Args:
service_name (str): The name of the service.
service_status_msg (StatusMessage): The status of the service.
metrics (dict): The metrics of the service.
"""
item_widget = self._create_status_widget(
service_name=service_name, status=status, info=info, metrics=metrics
)
item = QTreeWidgetItem()
self.service_update.connect(item_widget.update_config)
self.tree_top_item.addChild(item)
self.setItemWidget(item, 0, item_widget)
self.tree_items.update({service_name: (item, item_widget)})
def init_ui_tree_widget(self) -> None:
"""Initialise the tree widget for the status box."""
self.setHeaderHidden(True)
self.setStyleSheet(
"QTreeWidget::item:!selected "
"{ "
"border: 1px solid gainsboro; "
"border-left: none; "
"border-top: none; "
"}"
"QTreeWidget::item:selected {}"
)
@Slot(QTreeWidgetItem, int)
def on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
"""Callback function for double clicks on individual QTreeWidgetItems in the collapsed section.
Args:
item (QTreeWidgetItem): The item that was double clicked.
column (int): The column that was double clicked.
"""
for _, (tree_item, status_widget) in self.tree_items.items():
if tree_item == item:
status_widget.show_popup()
def closeEvent(self, event):
super().cleanup()
return QTreeWidget.closeEvent(self, event)
def main():
"""Main method to run the BECStatusBox widget."""
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
qdarktheme.setup_theme("auto")
main_window = BECStatusBox()
main_window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@@ -1,171 +0,0 @@
""" Module for a StatusItem widget to display status and metrics for a BEC service.
The widget is bound to be used with the BECStatusBox widget."""
import enum
import sys
from datetime import datetime
import qdarktheme
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import Field
from qtpy.QtCore import Qt, Slot
from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QStyle, QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
# TODO : Put normal imports back when Pydantic gets faster
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
class IconsEnum(enum.Enum):
"""Enum class for icons in the status item widget."""
RUNNING = "SP_DialogApplyButton"
BUSY = "SP_BrowserReload"
IDLE = "SP_MessageBoxWarning"
ERROR = "SP_DialogCancelButton"
NOTCONNECTED = "SP_TitleBarContextHelpButton"
class StatusWidgetConfig(ConnectionConfig):
"""Configuration class for the status item widget."""
service_name: str
status: str
info: dict
metrics: dict | None
icon_size: tuple = Field(default=(24, 24), description="The size of the icon in the widget.")
font_size: int = Field(16, description="The font size of the text in the widget.")
class StatusItem(QWidget):
"""A widget to display the status of a service.
Args:
parent: The parent widget.
config (dict): The configuration for the service.
"""
def __init__(self, parent=None, config: dict = None):
if config is None:
config = StatusWidgetConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = StatusWidgetConfig(**config)
self.config = config
QWidget.__init__(self, parent=parent)
self.parent = parent
self.layout = None
self.config = config
self._popup_label_ref = {}
self._label = None
self._icon = None
self.init_ui()
def init_ui(self) -> None:
"""Init the UI for the status item widget."""
self.layout = QHBoxLayout()
self.layout.setContentsMargins(5, 5, 5, 5)
self.setLayout(self.layout)
self._label = QLabel()
self._icon = QLabel()
self.layout.addWidget(self._label)
self.layout.addWidget(self._icon)
self.update_ui()
@Slot(dict)
def update_config(self, config: dict) -> None:
"""Update the configuration of the status item widget.
This method is invoked from the parent widget.
The UI values are later updated based on the new configuration.
Args:
config (dict): Config updates from parent widget.
"""
if config["service_name"] != self.config.service_name:
return
self.config.status = config["status"]
self.config.info = config["info"]
self.config.metrics = config["metrics"]
self.update_ui()
def update_ui(self) -> None:
"""Update the UI of the labels, and popup dialog."""
self.set_text()
self.set_status()
self._set_popup_text()
def set_text(self) -> None:
"""Set the text of the QLabel basae on the config."""
service = self.config.service_name
status = self.config.status
if "BECClient" in service.split("/"):
service = service.split("/")[0] + "/..." + service.split("/")[1][-4:]
if status == "NOTCONNECTED":
status = "NOT CONNECTED"
text = f"{service} is {status}"
self._label.setText(text)
def set_status(self) -> None:
"""Set the status icon for the status item widget."""
icon_name = IconsEnum[self.config.status].value
icon = self.style().standardIcon(getattr(QStyle.StandardPixmap, icon_name))
self._icon.setPixmap(icon.pixmap(*self.config.icon_size))
self._icon.setAlignment(Qt.AlignmentFlag.AlignRight)
def show_popup(self) -> None:
"""Method that is invoked when the user double clicks on the StatusItem widget."""
dialog = QDialog(self)
dialog.setWindowTitle(f"{self.config.service_name} Details")
layout = QVBoxLayout()
popup_label = self._make_popup_label()
self._set_popup_text()
layout.addWidget(popup_label)
dialog.setLayout(layout)
dialog.finished.connect(self._cleanup_popup_label)
dialog.exec()
def _make_popup_label(self) -> QLabel:
"""Create a QLabel for the popup dialog.
Returns:
QLabel: The label for the popup dialog.
"""
label = QLabel()
label.setWordWrap(True)
self._popup_label_ref.update({"label": label})
return label
def _set_popup_text(self) -> None:
"""Compile the metrics text for the status item widget."""
if self._popup_label_ref.get("label") is None:
return
metrics_text = (
f"<b>SERVICE:</b> {self.config.service_name}<br><b>STATUS:</b> {self.config.status}<br>"
)
if self.config.metrics:
for key, value in self.config.metrics.items():
if key == "create_time":
value = datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M:%S")
metrics_text += f"<b>{key.upper()}:</b> {value}<br>"
self._popup_label_ref["label"].setText(metrics_text)
def _cleanup_popup_label(self) -> None:
"""Cleanup the popup label."""
self._popup_label_ref.clear()
def main():
"""Run the status item widget."""
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
qdarktheme.setup_theme("auto")
main_window = StatusItem()
main_window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -1,17 +0,0 @@
import pyqtgraph as pg
class ColorButton(pg.ColorButton):
"""
A ColorButton that opens a dialog to select a color. Inherits from pyqtgraph.ColorButton.
Patches event loop of the ColorDialog, if opened in another QDialog.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def selectColor(self):
self.origColor = self.color()
self.colorDialog.setCurrentColor(self.color())
self.colorDialog.open()
self.colorDialog.exec()

View File

@@ -1 +0,0 @@
{'files': ['color_button.py']}

View File

@@ -1,55 +0,0 @@
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from bec_widgets.widgets.buttons.color_button.color_button import ColorButton
DOM_XML = """
<ui language='c++'>
<widget class='ColorButton' name='color_button'>
</widget>
</ui>
"""
class ColorButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = ColorButton(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Buttons"
def icon(self):
current_path = os.path.dirname(__file__)
icon_path = os.path.join(current_path, "assets", "color_button.png")
return QIcon(icon_path)
def includeFile(self):
return "color_button"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "ColorButton"
def toolTip(self):
return "ColorButton which opens a color dialog."
def whatsThis(self):
return self.toolTip()

View File

@@ -1,15 +0,0 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.buttons.color_button.color_button_plugin import ColorButtonPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(ColorButtonPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,496 +0,0 @@
"""
BECConsole is a Qt widget that runs a Bash shell. The widget can be used and
embedded like any other Qt widget.
BECConsole is powered by Pyte, a Python based terminal emulator
(https://github.com/selectel/pyte).
"""
import fcntl
import html
import os
import pty
import subprocess
import sys
import threading
import pyte
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import QSize, QSocketNotifier, Qt
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtGui import QClipboard, QTextCursor
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
ansi_colors = {
"black": "#000000",
"red": "#CD0000",
"green": "#00CD00",
"brown": "#996633", # Brown, replacing the yellow
"blue": "#0000EE",
"magenta": "#CD00CD",
"cyan": "#00CDCD",
"white": "#E5E5E5",
"brightblack": "#7F7F7F",
"brightred": "#FF0000",
"brightgreen": "#00FF00",
"brightyellow": "#FFFF00",
"brightblue": "#5C5CFF",
"brightmagenta": "#FF00FF",
"brightcyan": "#00FFFF",
"brightwhite": "#FFFFFF",
}
control_keys_mapping = {
QtCore.Qt.Key_A: b"\x01", # Ctrl-A
QtCore.Qt.Key_B: b"\x02", # Ctrl-B
QtCore.Qt.Key_C: b"\x03", # Ctrl-C
QtCore.Qt.Key_D: b"\x04", # Ctrl-D
QtCore.Qt.Key_E: b"\x05", # Ctrl-E
QtCore.Qt.Key_F: b"\x06", # Ctrl-F
QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell)
QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace)
QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab)
QtCore.Qt.Key_J: b"\x0A", # Ctrl-J (Line Feed)
QtCore.Qt.Key_K: b"\x0B", # Ctrl-K (Vertical Tab)
QtCore.Qt.Key_L: b"\x0C", # Ctrl-L (Form Feed)
QtCore.Qt.Key_M: b"\x0D", # Ctrl-M (Carriage Return)
QtCore.Qt.Key_N: b"\x0E", # Ctrl-N
QtCore.Qt.Key_O: b"\x0F", # Ctrl-O
QtCore.Qt.Key_P: b"\x10", # Ctrl-P
QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q
QtCore.Qt.Key_R: b"\x12", # Ctrl-R
QtCore.Qt.Key_S: b"\x13", # Ctrl-S
QtCore.Qt.Key_T: b"\x14", # Ctrl-T
QtCore.Qt.Key_U: b"\x15", # Ctrl-U
QtCore.Qt.Key_V: b"\x16", # Ctrl-V
QtCore.Qt.Key_W: b"\x17", # Ctrl-W
QtCore.Qt.Key_X: b"\x18", # Ctrl-X
QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y
QtCore.Qt.Key_Z: b"\x1A", # Ctrl-Z
QtCore.Qt.Key_Escape: b"\x1B", # Ctrl-Escape
QtCore.Qt.Key_Backslash: b"\x1C", # Ctrl-\
QtCore.Qt.Key_Underscore: b"\x1F", # Ctrl-_
}
normal_keys_mapping = {
QtCore.Qt.Key_Return: b"\n",
QtCore.Qt.Key_Space: b" ",
QtCore.Qt.Key_Enter: b"\n",
QtCore.Qt.Key_Tab: b"\t",
QtCore.Qt.Key_Backspace: b"\x08",
QtCore.Qt.Key_Home: b"\x47",
QtCore.Qt.Key_End: b"\x4f",
QtCore.Qt.Key_Left: b"\x02",
QtCore.Qt.Key_Up: b"\x10",
QtCore.Qt.Key_Right: b"\x06",
QtCore.Qt.Key_Down: b"\x0E",
QtCore.Qt.Key_PageUp: b"\x49",
QtCore.Qt.Key_PageDown: b"\x51",
QtCore.Qt.Key_F1: b"\x1b\x31",
QtCore.Qt.Key_F2: b"\x1b\x32",
QtCore.Qt.Key_F3: b"\x1b\x33",
QtCore.Qt.Key_F4: b"\x1b\x34",
QtCore.Qt.Key_F5: b"\x1b\x35",
QtCore.Qt.Key_F6: b"\x1b\x36",
QtCore.Qt.Key_F7: b"\x1b\x37",
QtCore.Qt.Key_F8: b"\x1b\x38",
QtCore.Qt.Key_F9: b"\x1b\x39",
QtCore.Qt.Key_F10: b"\x1b\x30",
QtCore.Qt.Key_F11: b"\x45",
QtCore.Qt.Key_F12: b"\x46",
}
def QtKeyToAscii(event):
"""
Convert the Qt key event to the corresponding ASCII sequence for
the terminal. This works fine for standard alphanumerical characters, but
most other characters require terminal specific control sequences.
The conversion below works for TERM="linux" terminals.
"""
if sys.platform == "darwin":
# special case for MacOS
# /!\ Qt maps ControlModifier to CMD
# CMD-C, CMD-V for copy/paste
# CTRL-C and other modifiers -> key mapping
if event.modifiers() == QtCore.Qt.MetaModifier:
if event.key() == Qt.Key_Backspace:
return control_keys_mapping.get(Qt.Key_W)
return control_keys_mapping.get(event.key())
elif event.modifiers() == QtCore.Qt.ControlModifier:
if event.key() == Qt.Key_C:
# copy
return "copy"
elif event.key() == Qt.Key_V:
# paste
return "paste"
return None
else:
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
if event.modifiers() == QtCore.Qt.ControlModifier:
return control_keys_mapping.get(event.key())
else:
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
class Screen(pyte.HistoryScreen):
def __init__(self, stdin_fd, numColumns, numLines, historyLength):
super().__init__(numColumns, numLines, historyLength, ratio=1 / numLines)
self._fd = stdin_fd
def write_process_input(self, data):
"""Response to CPR request for example"""
os.write(self._fd, data.encode("utf-8"))
class Backend(QtCore.QObject):
"""
Poll Bash.
This class will run as a qsocketnotifier (started in ``_TerminalWidget``) and poll the
file descriptor of the Bash terminal.
"""
# Signals to communicate with ``_TerminalWidget``.
startWork = pyqtSignal()
dataReady = pyqtSignal(object)
def __init__(self, fd, numColumns, numLines):
super().__init__()
# File descriptor that connects to Bash process.
self.fd = fd
# Setup Pyte (hard coded display size for now).
self.screen = Screen(self.fd, numColumns, numLines, 10000)
self.stream = pyte.ByteStream()
self.stream.attach(self.screen)
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
self.notifier.activated.connect(self._fd_readable)
def _fd_readable(self):
"""
Poll the Bash output, run it through Pyte, and notify the main applet.
"""
# Read the shell output until the file descriptor is closed.
try:
out = os.read(self.fd, 2**16)
except OSError:
return
# Feed output into Pyte's state machine and send the new screen
# output to the GUI
self.stream.feed(out)
self.dataReady.emit(self.screen)
class BECConsole(QtWidgets.QScrollArea):
"""Container widget for the terminal text area"""
def __init__(self, parent=None, numLines=50, numColumns=125):
super().__init__(parent)
self.innerWidget = QtWidgets.QWidget(self)
QHBoxLayout(self.innerWidget)
self.innerWidget.layout().setContentsMargins(0, 0, 0, 0)
self.term = _TerminalWidget(self.innerWidget, numLines, numColumns)
self.term.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.innerWidget.layout().addWidget(self.term)
self.scroll_bar = QScrollBar(Qt.Vertical, self.term)
self.innerWidget.layout().addWidget(self.scroll_bar)
self.term.set_scroll(self.scroll_bar)
self.setWidget(self.innerWidget)
def start(self, cmd=["bec", "--nogui"], deactivate_ctrl_d=True):
self.term._cmd = cmd
self.term.start(deactivate_ctrl_d=deactivate_ctrl_d)
def push(self, text):
"""Push some text to the terminal"""
return self.term.push(text)
class _TerminalWidget(QtWidgets.QPlainTextEdit):
"""
Start ``Backend`` process and render Pyte output as text.
"""
def __init__(self, parent, numColumns, numLines, **kwargs):
super().__init__(parent)
# file descriptor to communicate with the subprocess
self.fd = None
self.backend = None
self.lock = threading.Lock()
# command to execute
self._cmd = None
# should ctrl-d be deactivated ? (prevent Python exit)
self._deactivate_ctrl_d = False
# Specify the terminal size in terms of lines and columns.
self.numLines = numLines
self.numColumns = numColumns
self.output = [""] * numLines
# Use Monospace fonts and disable line wrapping.
self.setFont(QtGui.QFont("Courier", 9))
self.setFont(QtGui.QFont("Monospace"))
self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
# Disable vertical scrollbar (we use our own, to be set via .set_scroll())
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
fmt = QtGui.QFontMetrics(self.font())
self._char_width = fmt.width("w")
self._char_height = fmt.height()
self.setCursorWidth(self._char_width)
# self.setStyleSheet("QPlainTextEdit { color: #ffff00; background-color: #303030; } ");
def start(self, deactivate_ctrl_d=False):
self._deactivate_ctrl_d = deactivate_ctrl_d
# Start the Bash process
self.fd = self.forkShell()
# Create the ``Backend`` object
self.backend = Backend(self.fd, self.numColumns, self.numLines)
self.backend.dataReady.connect(self.dataReady)
def minimumSizeHint(self):
width = self._char_width * self.numColumns
height = self._char_height * self.numLines
return QSize(width, height + 20)
def set_scroll(self, scroll):
self.scroll = scroll
self.scroll.setMinimum(0)
self.scroll.valueChanged.connect(self.scroll_value_change)
def scroll_value_change(self, value, old={"value": 0}):
if value <= old["value"]:
# scroll up
# value is number of lines from the start
nlines = old["value"] - value
# history ratio gives prev_page == 1 line
for i in range(nlines):
self.backend.screen.prev_page()
else:
# scroll down
nlines = value - old["value"]
for i in range(nlines):
self.backend.screen.next_page()
old["value"] = value
self.dataReady(self.backend.screen, reset_scroll=False)
@pyqtSlot(object)
def keyPressEvent(self, event):
"""
Redirect all keystrokes to the terminal process.
"""
# Convert the Qt key to the correct ASCII code.
if (
self._deactivate_ctrl_d
and event.modifiers() == QtCore.Qt.ControlModifier
and event.key() == QtCore.Qt.Key_D
):
return None
code = QtKeyToAscii(event)
if code == "copy":
# MacOS only: CMD-C handling
self.copy()
elif code == "paste":
# MacOS only: CMD-V handling
self._push_clipboard()
elif code is not None:
os.write(self.fd, code)
def push(self, text):
"""
Write 'text' to terminal
"""
os.write(self.fd, text.encode("utf-8"))
def contextMenuEvent(self, event):
menu = self.createStandardContextMenu()
for action in menu.actions():
# remove all actions except copy and paste
if "opy" in action.text():
# redefine text without shortcut
# since it probably clashes with control codes (like CTRL-C etc)
action.setText("Copy")
continue
if "aste" in action.text():
# redefine text without shortcut
action.setText("Paste")
# paste -> have to insert with self.push
action.triggered.connect(self._push_clipboard)
continue
menu.removeAction(action)
menu.exec_(event.globalPos())
def _push_clipboard(self):
clipboard = QApplication.instance().clipboard()
self.push(clipboard.text())
def mouseReleaseEvent(self, event):
if event.button() == Qt.MiddleButton:
# push primary selection buffer ("mouse clipboard") to terminal
clipboard = QApplication.instance().clipboard()
if clipboard.supportsSelection():
self.push(clipboard.text(QClipboard.Selection))
return None
elif event.button() == Qt.LeftButton:
# left button click
textCursor = self.textCursor()
if textCursor.selectedText():
# mouse was used to select text -> nothing to do
pass
else:
# a simple 'click', make cursor going to end
textCursor.setPosition(0)
textCursor.movePosition(
QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
)
textCursor.movePosition(
QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
)
self.setTextCursor(textCursor)
self.ensureCursorVisible()
return None
return super().mouseReleaseEvent(event)
def dataReady(self, screenData, reset_scroll=True):
"""
Render the new screen as text into the widget.
This method is triggered via a signal from ``Backend``.
"""
with self.lock:
# Clear the widget
self.clear()
# Prepare the HTML output
for line_no in screenData.dirty:
line = text = ""
style = old_style = ""
for ch in screenData.buffer[line_no].values():
style = f"{'background-color:%s;' % ansi_colors.get(ch.bg, ansi_colors['black']) if ch.bg!='default' else ''}{'color:%s;' % ansi_colors.get(ch.fg, ansi_colors['white']) if ch.fg!='default' else ''}{'font-weight:bold;' if ch.bold else ''}{'font-style:italic;' if ch.italics else ''}"
if style != old_style:
if old_style:
line += f"<span style={repr(old_style)}>{html.escape(text, quote=True)}</span>"
else:
line += html.escape(text, quote=True)
text = ""
old_style = style
text += ch.data
if style:
line += f"<span style={repr(style)}>{html.escape(text, quote=True)}</span>"
else:
line += html.escape(text, quote=True)
self.output[line_no] = line
# fill the text area with HTML contents in one go
self.appendHtml(f"<pre>{chr(10).join(self.output)}</pre>")
# done updates, all clean
screenData.dirty.clear()
# Activate cursor
textCursor = self.textCursor()
textCursor.setPosition(0)
textCursor.movePosition(QTextCursor.Down, QTextCursor.MoveAnchor, screenData.cursor.y)
textCursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, screenData.cursor.x)
self.setTextCursor(textCursor)
self.ensureCursorVisible()
# manage scroll
if reset_scroll:
self.scroll.valueChanged.disconnect(self.scroll_value_change)
tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
self.scroll.setMaximum(tmp if tmp > 0 else 0)
self.scroll.setSliderPosition(len(self.backend.screen.history.top))
self.scroll.valueChanged.connect(self.scroll_value_change)
# def resizeEvent(self, event):
# with self.lock:
# self.numColumns = int(self.width() / self._char_width)
# self.numLines = int(self.height() / self._char_height)
# self.output = [""] * self.numLines
# print("RESIZING TO", self.numColumns, "x", self.numLines)
# self.backend.screen.resize(self.numLines, self.numColumns)
def wheelEvent(self, event):
y = event.angleDelta().y()
if y > 0:
self.backend.screen.prev_page()
else:
self.backend.screen.next_page()
self.dataReady(self.backend.screen, reset_scroll=False)
def forkShell(self):
"""
Fork the current process and execute bec in shell.
"""
try:
pid, fd = pty.fork()
except (IOError, OSError):
return False
if pid == 0:
# Safe way to make it work under BSD and Linux
try:
ls = os.environ["LANG"].split(".")
except KeyError:
ls = []
if len(ls) < 2:
ls = ["en_US", "UTF-8"]
try:
os.putenv("COLUMNS", str(self.numColumns))
os.putenv("LINES", str(self.numLines))
os.putenv("TERM", "linux")
os.putenv("LANG", ls[0] + ".UTF-8")
if isinstance(self._cmd, str):
os.execvp(self._cmd, self._cmd)
else:
os.execvp(self._cmd[0], self._cmd)
# print "child_pid", child_pid, sts
except (IOError, OSError):
pass
# self.proc_finish(sid)
os._exit(0)
else:
# We are in the parent process.
# Set file control
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
print("Spawned Bash shell (PID {})".format(pid))
return fd
if __name__ == "__main__":
import os
import sys
from qtpy import QtGui, QtWidgets
# Terminal size in characters.
numLines = 25
numColumns = 100
# Create the Qt application and QBash instance.
app = QtWidgets.QApplication([])
mainwin = QtWidgets.QMainWindow()
title = "BECConsole ({}x{})".format(numColumns, numLines)
mainwin.setWindowTitle(title)
console = BECConsole(mainwin, numColumns, numLines)
mainwin.setCentralWidget(console)
console.start()
# Show widget and launch Qt's event loop.
mainwin.show()
sys.exit(app.exec_())

View File

@@ -1,2 +0,0 @@
from .device_combobox.device_combobox import DeviceComboBox
from .device_line_edit.device_line_edit import DeviceLineEdit

View File

@@ -1,92 +0,0 @@
from typing import TYPE_CHECKING
from qtpy.QtWidgets import QComboBox
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputBase, DeviceInputConfig
if TYPE_CHECKING:
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputConfig
class DeviceComboBox(DeviceInputBase, QComboBox):
"""
Line edit widget for device input with autocomplete for device names.
Args:
parent: Parent widget.
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
def __init__(
self,
parent=None,
client=None,
config: DeviceInputConfig = None,
gui_id: str | None = None,
device_filter: str | None = None,
default: str | None = None,
arg_name: str | None = None,
):
super().__init__(client=client, config=config, gui_id=gui_id)
QComboBox.__init__(self, parent=parent)
self.populate_combobox()
if arg_name is not None:
self.config.arg_name = arg_name
if device_filter is not None:
self.set_device_filter(device_filter)
if default is not None:
self.set_default_device(default)
def set_device_filter(self, device_filter: str):
"""
Set the device filter.
Args:
device_filter(str): Device filter, name of the device class.
"""
super().set_device_filter(device_filter)
self.populate_combobox()
def set_default_device(self, default_device: str):
"""
Set the default device.
Args:
default_device(str): Default device name.
"""
super().set_default_device(default_device)
self.setCurrentText(default_device)
def populate_combobox(self):
"""Populate the combobox with the devices."""
self.devices = self.get_device_list(self.config.device_filter)
self.clear()
self.addItems(self.devices)
def get_device(self) -> object:
"""
Get the selected device object.
Returns:
object: Device object.
"""
device_name = self.currentText()
device_obj = getattr(self.dev, device_name.lower(), None)
if device_obj is None:
raise ValueError(f"Device {device_name} is not found.")
return device_obj
def cleanup(self):
"""Cleanup the widget."""
super().cleanup()
def closeEvent(self, event):
super().cleanup()
return QComboBox.closeEvent(self, event)

View File

@@ -1,4 +0,0 @@
{
"files": ["device_combobox.py", "launch_device_combobox.py",
]
}

View File

@@ -1,54 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from bec_widgets.widgets.device_inputs import DeviceComboBox
DOM_XML = """
<ui language='c++'>
<widget class='DeviceComboBox' name='device_combobox'>
</widget>
</ui>
"""
class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = DeviceComboBox(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return QIcon()
def includeFile(self):
return "device_combobox"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "DeviceComboBox"
def toolTip(self):
return "Device ComboBox Example for BEC Widgets"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,11 +0,0 @@
from bec_widgets.widgets.device_inputs import DeviceComboBox
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
w = DeviceComboBox()
w.show()
sys.exit(app.exec_())

View File

@@ -1,17 +0,0 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.device_inputs.device_combobox.device_combobox_plugin import (
DeviceComboBoxPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceComboBoxPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,123 +0,0 @@
from __future__ import annotations
from bec_widgets.utils import BECConnector, ConnectionConfig
class DeviceInputConfig(ConnectionConfig):
device_filter: str | list[str] | None = None
default: str | None = None
arg_name: str | None = None
class DeviceInputBase(BECConnector):
"""
Mixin class for device input widgets. This class provides methods to get the device list and device object based
on the current text of the widget.
"""
def __init__(self, client=None, config=None, gui_id=None):
if config is None:
config = DeviceInputConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceInputConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
self.get_bec_shortcuts()
self._devices = []
@property
def devices(self) -> list[str]:
"""
Get the list of devices.
Returns:
list[str]: List of devices.
"""
return self._devices
@devices.setter
def devices(self, value: list[str]):
"""
Set the list of devices.
Args:
value: List of devices.
"""
self._devices = value
def set_device_filter(self, device_filter: str | list[str]):
"""
Set the device filter.
Args:
device_filter(str): Device filter, name of the device class.
"""
self.validate_device_filter(device_filter)
self.config.device_filter = device_filter
def set_default_device(self, default_device: str):
"""
Set the default device.
Args:
default_device(str): Default device name.
"""
self.validate_device(default_device)
self.config.default = default_device
def get_device_list(self, filter: str | list[str] | None = None) -> list[str]:
"""
Get the list of device names based on the filter of current BEC client.
Args:
filter(str|None): Class name filter to apply on the device list.
Returns:
devices(list[str]): List of device names.
"""
all_devices = self.dev.enabled_devices
if filter is not None:
self.validate_device_filter(filter)
if isinstance(filter, str):
filter = [filter]
devices = [device.name for device in all_devices if device.__class__.__name__ in filter]
else:
devices = [device.name for device in all_devices]
return devices
def get_available_filters(self):
"""
Get the available device classes which can be used as filters.
"""
all_devices = self.dev.enabled_devices
filters = {device.__class__.__name__ for device in all_devices}
return filters
def validate_device_filter(self, filter: str | list[str]) -> None:
"""
Validate the device filter if the class name is present in the current BEC instance.
Args:
filter(str|list[str]): Class name to use as a device filter.
"""
if isinstance(filter, str):
filter = [filter]
available_filters = self.get_available_filters()
for f in filter:
if f not in available_filters:
raise ValueError(f"Device filter {f} is not valid.")
def validate_device(self, device: str) -> None:
"""
Validate the device if it is present in current BEC instance.
Args:
device(str): Device to validate.
"""
if device not in self.get_device_list(self.config.device_filter):
raise ValueError(f"Device {device} is not valid.")
def cleanup(self):
super().cleanup()

View File

@@ -1,104 +0,0 @@
from typing import TYPE_CHECKING
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QCompleter, QLineEdit, QSizePolicy
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputBase, DeviceInputConfig
if TYPE_CHECKING:
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputConfig
class DeviceLineEdit(DeviceInputBase, QLineEdit):
"""
Line edit widget for device input with autocomplete for device names.
Args:
parent: Parent widget.
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
def __init__(
self,
parent=None,
client=None,
config: DeviceInputConfig = None,
gui_id: str | None = None,
device_filter: str | list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
):
QLineEdit.__init__(self, parent=parent)
DeviceInputBase.__init__(self, client=client, config=config, gui_id=gui_id)
self.completer = QCompleter(self)
self.setCompleter(self.completer)
self.populate_completer()
if arg_name is not None:
self.config.arg_name = arg_name
self.arg_name = arg_name
if device_filter is not None:
self.set_device_filter(device_filter)
if default is not None:
self.set_default_device(default)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.setMinimumSize(QSize(100, 0))
def set_device_filter(self, device_filter: str | list[str]):
"""
Set the device filter.
Args:
device_filter (str | list[str]): Device filter, name of the device class.
"""
super().set_device_filter(device_filter)
self.populate_completer()
def set_default_device(self, default_device: str):
"""
Set the default device.
Args:
default_device (str): Default device name.
"""
super().set_default_device(default_device)
self.setText(default_device)
def populate_completer(self):
"""Populate the completer with the devices."""
self.devices = self.get_device_list(self.config.device_filter)
self.completer.setModel(self.create_completer_model(self.devices))
def create_completer_model(self, devices: list[str]):
"""Create a model for the completer."""
from qtpy.QtCore import QStringListModel
return QStringListModel(devices, self)
def get_device(self) -> object:
"""
Get the selected device object.
Returns:
object: Device object.
"""
device_name = self.text()
device_obj = getattr(self.dev, device_name.lower(), None)
if device_obj is None:
raise ValueError(f"Device {device_name} is not found.")
return device_obj
def cleanup(self):
"""Cleanup the widget."""
super().cleanup()
def closeEvent(self, event):
super().cleanup()
return QLineEdit.closeEvent(self, event)

View File

@@ -1,4 +0,0 @@
{
"files": ["device_line_edit.py", "launch_device_line_edit.py",
]
}

View File

@@ -1,54 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from bec_widgets.widgets.device_inputs import DeviceLineEdit
DOM_XML = """
<ui language='c++'>
<widget class='DeviceLineEdit' name='device_line_edit'>
</widget>
</ui>
"""
class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = DeviceLineEdit(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return QIcon()
def includeFile(self):
return "device_line_edit"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "DeviceLineEdit"
def toolTip(self):
return "Device LineEdit Example for BEC Widgets with autocomplete."
def whatsThis(self):
return self.toolTip()

View File

@@ -1,11 +0,0 @@
from bec_widgets.widgets.device_inputs import DeviceLineEdit
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
w = DeviceLineEdit()
w.show()
sys.exit(app.exec_())

View File

@@ -1,17 +0,0 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.device_inputs.device_line_edit.device_line_edit_plugin import (
DeviceLineEditPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceLineEditPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -5,13 +5,13 @@ from typing import TYPE_CHECKING, Any, Literal, Optional
from pydantic import Field
from pyqtgraph.dockarea import Dock
from bec_widgets.cli.rpc_wigdet_handler import widget_handler
from bec_widgets.cli.rpc_wigdet_handler import RPCWidgetHandler
from bec_widgets.utils import BECConnector, ConnectionConfig, GridLayoutManager
if TYPE_CHECKING:
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.dock import BECDockArea
from bec_widgets.widgets import BECDockArea
class DockConfig(ConnectionConfig):
@@ -26,8 +26,8 @@ class DockConfig(ConnectionConfig):
class BECDock(BECConnector, Dock):
USER_ACCESS = [
"_config_dict",
"_rpc_id",
"config_dict",
"rpc_id",
"widget_list",
"show_title_bar",
"hide_title_bar",
@@ -149,7 +149,7 @@ class BECDock(BECConnector, Dock):
Returns:
list: The list of eligible widgets.
"""
return list(widget_handler.widget_classes.keys())
return list(RPCWidgetHandler.widget_classes.keys())
def add_widget(
self,
@@ -178,7 +178,7 @@ class BECDock(BECConnector, Dock):
self.layout_manager.shift_widgets(shift, start_row=row)
if isinstance(widget, str):
widget = widget_handler.create_widget(widget)
widget = RPCWidgetHandler.create_widget(widget)
else:
widget = widget

View File

@@ -23,7 +23,7 @@ class DockAreaConfig(ConnectionConfig):
class BECDockArea(BECConnector, DockArea):
USER_ACCESS = [
"_config_dict",
"config_dict",
"panels",
"save_state",
"remove_dock",
@@ -32,7 +32,7 @@ class BECDockArea(BECConnector, DockArea):
"clear_all",
"detach_dock",
"attach_all",
"_get_all_rpc",
"get_all_rpc",
"temp_areas",
]

View File

@@ -8,7 +8,7 @@ from typing import Literal, Optional
import numpy as np
import pyqtgraph as pg
import qdarktheme
from pydantic import Field, ValidationError, field_validator
from pydantic import Field
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtWidgets import QWidget
from typeguard import typechecked
@@ -30,36 +30,16 @@ class FigureConfig(ConnectionConfig):
{}, description="The list of widgets to be added to the figure widget."
)
@field_validator("widgets", mode="before")
@classmethod
def validate_widgets(cls, v):
"""Validate the widgets configuration."""
widget_class_map = {
"BECWaveform": Waveform1DConfig,
"BECImageShow": ImageConfig,
"BECMotorMap": MotorMapConfig,
}
validated_widgets = {}
for key, widget_config in v.items():
if "widget_class" not in widget_config:
raise ValueError(f"Widget config for {key} does not contain 'widget_class'.")
widget_class = widget_config["widget_class"]
if widget_class not in widget_class_map:
raise ValueError(f"Unknown widget_class '{widget_class}' for widget '{key}'.")
config_class = widget_class_map[widget_class]
validated_widgets[key] = config_class(**widget_config)
return validated_widgets
class WidgetHandler:
"""Factory for creating and configuring BEC widgets for BECFigure."""
def __init__(self):
self.widget_factory = {
"BECPlotBase": (BECPlotBase, SubplotConfig),
"BECWaveform": (BECWaveform, Waveform1DConfig),
"BECImageShow": (BECImageShow, ImageConfig),
"BECMotorMap": (BECMotorMap, MotorMapConfig),
"PlotBase": (BECPlotBase, SubplotConfig),
"Waveform1D": (BECWaveform, Waveform1DConfig),
"ImShow": (BECImageShow, ImageConfig),
"MotorMap": (BECMotorMap, MotorMapConfig),
}
def create_widget(
@@ -110,11 +90,13 @@ class WidgetHandler:
class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"_get_all_rpc",
"rpc_id",
"config_dict",
"axes",
"widgets",
"add_plot",
"add_image",
"add_motor_map",
"plot",
"image",
"motor_map",
@@ -122,15 +104,9 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
"change_layout",
"change_theme",
"clear_all",
"get_all_rpc",
"widget_list",
]
subplot_map = {
"PlotBase": BECPlotBase,
"BECWaveform": BECWaveform,
"BECImageShow": BECImageShow,
"BECMotorMap": BECMotorMap,
}
widget_method_map = {"BECWaveform": "plot", "BECImageShow": "image", "BECMotorMap": "motor_map"}
clean_signal = pyqtSignal()
@@ -146,7 +122,8 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
else:
if isinstance(config, dict):
config = FigureConfig(**config)
super().__init__(client=client, gui_id=gui_id)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
pg.GraphicsLayoutWidget.__init__(self, parent)
self.widget_handler = WidgetHandler()
@@ -156,8 +133,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
# Container to keep track of the grid
self.grid = []
# Create config and apply it
self.apply_config(config)
def __getitem__(self, key: tuple | str):
if isinstance(key, tuple) and len(key) == 2:
@@ -172,24 +147,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
"Key must be a string (widget id) or a tuple of two integers (grid coordinates)"
)
def apply_config(self, config: dict | FigureConfig): # ,generate_new_id: bool = False):
if isinstance(config, dict):
try:
config = FigureConfig(**config)
except ValidationError as e:
print(f"Error in applying config: {e}")
return
self.config = config
self.change_theme(self.config.theme)
# widget_config has to be reset for not have each widget config twice when added to the figure
widget_configs = [config for config in self.config.widgets.values()]
self.config.widgets = {}
for widget_config in widget_configs:
getattr(self, self.widget_method_map[widget_config.widget_class])(
config=widget_config.model_dump(), row=widget_config.row, col=widget_config.col
)
@property
def widget_list(self) -> list[BECPlotBase]:
"""
@@ -238,12 +195,11 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
z_entry: str = None,
x: list | np.ndarray = None,
y: list | np.ndarray = None,
color: str | None = None,
color_map_z: str | None = "plasma",
label: str | None = None,
color: Optional[str] = None,
color_map_z: Optional[str] = "plasma",
label: Optional[str] = None,
validate: bool = True,
dap: str | None = None,
) -> BECWaveform:
):
"""
Configure the waveform based on the provided parameters.
@@ -261,7 +217,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
color_map_z (str): The color map to use for the z-axis.
label (str): The label of the curve.
validate (bool): If True, validate the device names and entries.
dap (str): The DAP model to use for the curve.
"""
if x is not None and y is None:
if isinstance(x, np.ndarray):
@@ -285,7 +240,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
return waveform
# User wants to add scan curve -> 1D Waveform
if x_name is not None and y_name is not None and z_name is None and x is None and y is None:
waveform.plot(
waveform.add_curve_scan(
x_name=x_name,
y_name=y_name,
x_entry=x_entry,
@@ -293,7 +248,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
validate=validate,
color=color,
label=label,
dap=dap,
)
# User wants to add scan curve -> 2D Waveform Scatter
if (
@@ -303,7 +257,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
and x is None
and y is None
):
waveform.plot(
waveform.add_curve_scan(
x_name=x_name,
y_name=y_name,
z_name=z_name,
@@ -314,7 +268,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
color_map_z=color_map_z,
label=label,
validate=validate,
dap=dap,
)
# User wants to add custom curve
elif x is not None and y is not None and x_name is None and y_name is None:
@@ -322,6 +275,73 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
return waveform
def add_plot(
self,
x: list | np.ndarray = None,
y: list | np.ndarray = None,
x_name: str = None,
y_name: str = None,
z_name: str = None,
x_entry: str = None,
y_entry: str = None,
z_entry: str = None,
color: Optional[str] = None,
color_map_z: Optional[str] = "plasma",
label: Optional[str] = None,
validate: bool = True,
row: int = None,
col: int = None,
config=None,
**axis_kwargs,
) -> BECWaveform:
"""
Add a Waveform1D plot to the figure at the specified position.
Args:
x(list | np.ndarray): Custom x data to plot.
y(list | np.ndarray): Custom y data to plot.
x_name(str): The name of the device for the x-axis.
y_name(str): The name of the device for the y-axis.
z_name(str): The name of the device for the z-axis.
x_entry(str): The name of the entry for the x-axis.
y_entry(str): The name of the entry for the y-axis.
z_entry(str): The name of the entry for the z-axis.
color(str): The color of the curve.
color_map_z(str): The color map to use for the z-axis.
label(str): The label of the curve.
validate(bool): If True, validate the device names and entries.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Additional configuration for the widget.
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
"""
widget_id = str(uuid.uuid4())
waveform = self.add_widget(
widget_type="Waveform1D",
widget_id=widget_id,
row=row,
col=col,
config=config,
**axis_kwargs,
)
waveform = self._init_waveform(
waveform=waveform,
x=x,
y=y,
x_name=x_name,
y_name=y_name,
z_name=z_name,
x_entry=x_entry,
y_entry=y_entry,
z_entry=z_entry,
color=color,
color_map_z=color_map_z,
label=label,
validate=validate,
)
return waveform
@typechecked
def plot(
self,
@@ -337,11 +357,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
color_map_z: str | None = "plasma",
label: str | None = None,
validate: bool = True,
new: bool = False,
row: int | None = None,
col: int | None = None,
dap: str | None = None,
config: dict | None = None, # TODO make logic more transparent
**axis_kwargs,
) -> BECWaveform:
"""
@@ -360,23 +375,20 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
color_map_z(str): The color map to use for the z-axis.
label(str): The label of the curve.
validate(bool): If True, validate the device names and entries.
new(bool): If True, create a new plot instead of using the first plot.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
dap(str): The DAP model to use for the curve.
config(dict): Recreates the whole BECWaveform widget from provided configuration.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECWaveform: The waveform plot widget.
"""
waveform = self.subplot_factory(
widget_type="BECWaveform", config=config, row=row, col=col, new=new, **axis_kwargs
waveform = WidgetContainerUtils.find_first_widget_by_class(
self._widgets, BECWaveform, can_fail=True
)
if config is not None:
return waveform
if waveform is not None:
if axis_kwargs:
waveform.set(**axis_kwargs)
else:
waveform = self.add_plot(**axis_kwargs)
# Passing args to init_waveform
waveform = self._init_waveform(
waveform=waveform,
x=x,
@@ -391,8 +403,8 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
color_map_z=color_map_z,
label=label,
validate=validate,
dap=dap,
)
# TODO remove repetition from .plot method
return waveform
def _init_image(
@@ -439,10 +451,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
color_map: str = "magma",
data: np.ndarray = None,
vrange: tuple[float, float] = None,
new: bool = False,
row: int | None = None,
col: int | None = None,
config: dict | None = None,
**axis_kwargs,
) -> BECImageShow:
"""
@@ -454,21 +462,19 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
color_map(str): The color map to use for the image.
data(np.ndarray): Custom data to display.
vrange(tuple[float, float]): The range of values to display.
new(bool): If True, create a new plot instead of using the first plot.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Recreates the whole BECImageShow widget from provided configuration.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECImageShow: The image widget.
"""
image = self.subplot_factory(
widget_type="BECImageShow", config=config, row=row, col=col, new=new, **axis_kwargs
image = WidgetContainerUtils.find_first_widget_by_class(
self._widgets, BECImageShow, can_fail=True
)
if config is not None:
return image
if image is not None:
if axis_kwargs:
image.set(**axis_kwargs)
else:
image = self.add_image(color_bar=color_bar, **axis_kwargs)
image = self._init_image(
image=image,
@@ -480,99 +486,134 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
)
return image
def motor_map(
def add_image(
self,
motor_x: str = None,
motor_y: str = None,
new: bool = False,
row: int | None = None,
col: int | None = None,
config: dict | None = None,
monitor: str = None,
color_bar: Literal["simple", "full"] = "full",
color_map: str = "magma",
data: np.ndarray = None,
vrange: tuple[float, float] = None,
row: int = None,
col: int = None,
config=None,
**axis_kwargs,
) -> BECMotorMap:
) -> BECImageShow:
"""
Add an image to the figure at the specified position.
Args:
monitor(str): The name of the monitor to display.
color_bar(Literal["simple","full"]): The type of color bar to display.
color_map(str): The color map to use for the image.
data(np.ndarray): Custom data to display.
vrange(tuple[float, float]): The range of values to display.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Additional configuration for the widget.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECImageShow: The image widget.
"""
widget_id = str(uuid.uuid4())
if config is None:
config = ImageConfig(
widget_class="BECImageShow",
gui_id=widget_id,
parent_id=self.gui_id,
color_map=color_map,
color_bar=color_bar,
vrange=vrange,
)
image = self.add_widget(
widget_type="ImShow",
widget_id=widget_id,
row=row,
col=col,
config=config,
**axis_kwargs,
)
image = self._init_image(
image=image,
monitor=monitor,
color_bar=color_bar,
color_map=color_map,
data=data,
vrange=vrange,
)
return image
def motor_map(self, motor_x: str = None, motor_y: str = None, **axis_kwargs) -> BECMotorMap:
"""
Add a motor map to the figure. Always access the first motor map widget in the figure.
Args:
motor_x(str): The name of the motor for the X axis.
motor_y(str): The name of the motor for the Y axis.
new(bool): If True, create a new plot instead of using the first plot.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Recreates the whole BECImageShow widget from provided configuration.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECMotorMap: The motor map widget.
"""
motor_map = self.subplot_factory(
widget_type="BECMotorMap", config=config, row=row, col=col, new=new, **axis_kwargs
motor_map = WidgetContainerUtils.find_first_widget_by_class(
self._widgets, BECMotorMap, can_fail=True
)
if config is not None:
return motor_map
if motor_map is not None:
if axis_kwargs:
motor_map.set(**axis_kwargs)
else:
motor_map = self.add_motor_map(**axis_kwargs)
if motor_x is not None and motor_y is not None:
motor_map.change_motors(motor_x, motor_y)
return motor_map
def subplot_factory(
def add_motor_map(
self,
widget_type: Literal[
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap"
] = "BECPlotBase",
motor_x: str = None,
motor_y: str = None,
row: int = None,
col: int = None,
config=None,
new: bool = False,
**axis_kwargs,
) -> BECPlotBase:
# Case 1 - config provided, new plot, possible to define coordinates
if config is not None:
widget_cls = config["widget_class"]
if widget_cls != widget_type:
raise ValueError(
f"Widget type '{widget_type}' does not match the provided configuration ({widget_cls})."
)
widget = self.add_widget(
widget_type=widget_type, config=config, row=row, col=col, **axis_kwargs
) -> BECMotorMap:
"""
Args:
motor_x(str): The name of the motor for the X axis.
motor_y(str): The name of the motor for the Y axis.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Additional configuration for the widget.
**axis_kwargs:
Returns:
BECMotorMap: The motor map widget.
"""
widget_id = str(uuid.uuid4())
if config is None:
config = MotorMapConfig(
widget_class="BECMotorMap", gui_id=widget_id, parent_id=self.gui_id
)
return widget
motor_map = self.add_widget(
widget_type="MotorMap",
widget_id=widget_id,
row=row,
col=col,
config=config,
**axis_kwargs,
)
# Case 2 - find first plot or create first plot if no plot available, no config provided, no coordinates
if new is False and (row is None or col is None):
widget = WidgetContainerUtils.find_first_widget_by_class(
self._widgets, self.subplot_map[widget_type], can_fail=True
)
if widget is not None:
if axis_kwargs:
widget.set(**axis_kwargs)
else:
widget = self.add_widget(widget_type=widget_type, **axis_kwargs)
return widget
if motor_x is not None and motor_y is not None:
motor_map.change_motors(motor_x, motor_y)
# Case 3 - modifying existing plot wit coordinates provided
if new is False and (row is not None and col is not None):
try:
widget = self.axes(row, col)
except ValueError:
widget = None
if widget is not None:
if axis_kwargs:
widget.set(**axis_kwargs)
else:
widget = self.add_widget(widget_type=widget_type, row=row, col=col, **axis_kwargs)
return widget
# Case 4 - no previous plot or new plot, no config provided, possible to define coordinates
widget = self.add_widget(widget_type=widget_type, row=row, col=col, **axis_kwargs)
return widget
return motor_map
def add_widget(
self,
widget_type: Literal[
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap"
] = "BECPlotBase",
widget_type: Literal["PlotBase", "Waveform1D", "ImShow"] = "PlotBase",
widget_id: str = None,
row: int = None,
col: int = None,
@@ -603,9 +644,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
config=config,
**axis_kwargs,
)
# has to be changed manually to ensure unique id, if config is copied from existing widget, the id could be
# used otherwise multiple times
widget.set_gui_id(widget_id)
# Check if position is occupied
if row is not None and col is not None:
@@ -673,12 +711,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
qdarktheme.setup_theme(theme)
self.setBackground("k" if theme == "dark" else "w")
self.config.theme = theme
for plot in self.widget_list:
plot.set_x_label(plot.plot_item.getAxis("bottom").label.toPlainText())
plot.set_y_label(plot.plot_item.getAxis("left").label.toPlainText())
if plot.plot_item.titleLabel.text:
plot.set_title(plot.plot_item.titleLabel.text)
plot.set_legend_label_size()
def _remove_by_coordinates(self, row: int, col: int) -> None:
"""
@@ -709,7 +741,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
self._reindex_grid()
if widget_id in self.config.widgets:
self.config.widgets.pop(widget_id)
widget.deleteLater()
else:
raise ValueError(f"Widget with ID '{widget_id}' does not exist.")

View File

@@ -1,61 +0,0 @@
import os
import qdarktheme
from qtpy.QtCore import Slot
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.widget_io import WidgetIO
class AxisSettings(QWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
current_path = os.path.dirname(__file__)
self.ui = UILoader().load_ui(os.path.join(current_path, "axis_settings.ui"), self)
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.ui)
# Hardcoded values for best appearance
self.setMinimumHeight(280)
self.setMaximumHeight(280)
self.resize(380, 280)
@Slot(dict)
def display_current_settings(self, axis_config: dict):
# Top Box
WidgetIO.set_value(self.ui.plot_title, axis_config["title"])
# X Axis Box
WidgetIO.set_value(self.ui.x_label, axis_config["x_label"])
WidgetIO.set_value(self.ui.x_scale, axis_config["x_scale"])
WidgetIO.set_value(self.ui.x_grid, axis_config["x_grid"])
if axis_config["x_lim"] is not None:
WidgetIO.check_and_adjust_limits(self.ui.x_min, axis_config["x_lim"][0])
WidgetIO.check_and_adjust_limits(self.ui.x_max, axis_config["x_lim"][1])
WidgetIO.set_value(self.ui.x_min, axis_config["x_lim"][0])
WidgetIO.set_value(self.ui.x_max, axis_config["x_lim"][1])
# Y Axis Box
WidgetIO.set_value(self.ui.y_label, axis_config["y_label"])
WidgetIO.set_value(self.ui.y_scale, axis_config["y_scale"])
WidgetIO.set_value(self.ui.y_grid, axis_config["y_grid"])
if axis_config["y_lim"] is not None:
WidgetIO.check_and_adjust_limits(self.ui.y_min, axis_config["y_lim"][0])
WidgetIO.check_and_adjust_limits(self.ui.y_max, axis_config["y_lim"][1])
WidgetIO.set_value(self.ui.y_min, axis_config["y_lim"][0])
WidgetIO.set_value(self.ui.y_max, axis_config["y_lim"][1])
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
qdarktheme.setup_theme("dark")
window = AxisSettings()
window.show()
sys.exit(app.exec_())

View File

@@ -1,249 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>417</width>
<height>250</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>250</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>278</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0" colspan="3">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="plot_title"/>
</item>
</layout>
</item>
<item row="1" column="0" colspan="3">
<widget class="Line" name="line_H">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
<string>Scale</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="x_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="x_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="x_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QComboBox" name="x_scale">
<item>
<property name="text">
<string>linear</string>
</property>
</item>
<item>
<property name="text">
<string>log</string>
</property>
</item>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="x_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_label"/>
</item>
<item row="0" column="0">
<widget class="QLabel" name="x_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QCheckBox" name="x_grid">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="2" column="1">
<widget class="Line" name="line_V">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
<string>Y Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<item row="3" column="2">
<widget class="QComboBox" name="y_scale">
<item>
<property name="text">
<string>linear</string>
</property>
</item>
<item>
<property name="text">
<string>log</string>
</property>
</item>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="y_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="y_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="y_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="y_label"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="y_scale_label">
<property name="text">
<string>Scale</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="y_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="y_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QCheckBox" name="y_grid">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="y_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -5,18 +5,14 @@ from typing import Any, Literal, Optional
import numpy as np
from bec_lib.endpoints import MessageEndpoints
from pydantic import BaseModel, Field, ValidationError
from pydantic import Field, ValidationError
from qtpy.QtCore import QThread
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import EntryValidator
from bec_widgets.widgets.figure.plots.image.image_item import BECImageItem, ImageItemConfig
from bec_widgets.widgets.figure.plots.image.image_processor import (
ImageProcessor,
ImageStats,
ProcessorWorker,
)
from bec_widgets.widgets.figure.plots.image.image_processor import ImageProcessor, ProcessorWorker
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
@@ -29,15 +25,16 @@ class ImageConfig(SubplotConfig):
class BECImageShow(BECPlotBase):
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"rpc_id",
"config_dict",
"add_image_by_config",
"get_image_config",
"get_image_dict",
"add_monitor_image",
"add_custom_image",
"set_vrange",
"set_color_map",
"set_autorange",
"set_autorange_mode",
"set_monitor",
"set_processing",
"set_image_properties",
@@ -45,6 +42,7 @@ class BECImageShow(BECPlotBase):
"set_log",
"set_rotation",
"set_transpose",
"toggle_threading",
"set",
"set_title",
"set_x_label",
@@ -88,7 +86,6 @@ class BECImageShow(BECPlotBase):
# Connect signals and slots
thread.started.connect(lambda: worker.process_image(device, image))
worker.processed.connect(self.update_image)
worker.stats.connect(self.update_vrange)
worker.finished.connect(thread.quit)
worker.finished.connect(thread.wait)
worker.finished.connect(worker.deleteLater)
@@ -135,8 +132,7 @@ class BECImageShow(BECPlotBase):
self.apply_axis_config()
self._images = defaultdict(dict)
for image_id, image_config in config.images.items():
self.add_image_by_config(image_config)
# TODO extend by adding image by config
def change_gui_id(self, new_gui_id: str):
"""
@@ -224,7 +220,7 @@ class BECImageShow(BECPlotBase):
self,
monitor: str,
color_map: Optional[str] = "magma",
color_bar: Optional[Literal["simple", "full"]] = "full",
color_bar: Optional[Literal["simple", "full"]] = "simple",
downsample: Optional[bool] = True,
opacity: Optional[float] = 1.0,
vrange: Optional[tuple[int, int]] = None,
@@ -239,7 +235,7 @@ class BECImageShow(BECPlotBase):
f"Monitor with ID '{monitor}' already exists in widget '{self.gui_id}'."
)
# monitor = self.entry_validator.validate_monitor(monitor)
monitor = self.entry_validator.validate_monitor(monitor)
image_config = ImageItemConfig(
widget_class="BECImageItem",
@@ -249,13 +245,12 @@ class BECImageShow(BECPlotBase):
downsample=downsample,
opacity=opacity,
vrange=vrange,
source=image_source,
monitor=monitor,
# post_processing=post_processing,
**kwargs,
)
image = self._add_image_object(source=image_source, name=monitor, config=image_config)
self._connect_device_monitor(monitor)
return image
def add_custom_image(
@@ -263,17 +258,16 @@ class BECImageShow(BECPlotBase):
name: str,
data: Optional[np.ndarray] = None,
color_map: Optional[str] = "magma",
color_bar: Optional[Literal["simple", "full"]] = "full",
color_bar: Optional[Literal["simple", "full"]] = "simple",
downsample: Optional[bool] = True,
opacity: Optional[float] = 1.0,
vrange: Optional[tuple[int, int]] = None,
# post_processing: Optional[PostProcessingConfig] = None,
**kwargs,
):
image_source = "custom"
# image_source = "device_monitor"
image_source = "device_monitor"
image_exits = self._check_image_id(name, self._images)
image_exits = self._check_curve_id(name, self._images)
if image_exits:
raise ValueError(f"Monitor with ID '{name}' already exists in widget '{self.gui_id}'.")
@@ -290,9 +284,7 @@ class BECImageShow(BECPlotBase):
**kwargs,
)
image = self._add_image_object(
source=image_source, name=name, config=image_config, data=data
)
image = self._add_image_object(source=image_source, config=image_config, data=data)
return image
def apply_setting_to_images(
@@ -315,7 +307,6 @@ class BECImageShow(BECPlotBase):
for source, images in self._images.items():
for _, image in images.items():
getattr(image, setting_method_name)(*args, **kwargs)
self.refresh_image()
def set_vrange(self, vmin: float, vmax: float, name: str = None):
"""
@@ -350,17 +341,6 @@ class BECImageShow(BECPlotBase):
"""
self.apply_setting_to_images("set_autorange", args=[enable], kwargs={}, image_id=name)
def set_autorange_mode(self, mode: Literal["max", "mean"], name: str = None):
"""
Set the autoscale mode of the image, that decides how the vrange of the color bar is scaled.
Choose betwen 'max' -> min/max of the data, 'mean' -> mean +/- fudge_factor*std of the data (fudge_factor~2).
Args:
mode(str): The autoscale mode of the image.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_autorange_mode", args=[mode], kwargs={}, image_id=name)
def set_monitor(self, monitor: str, name: str = None):
"""
Set the monitor of the image.
@@ -463,27 +443,6 @@ class BECImageShow(BECPlotBase):
if self.use_threading is False and self.thread.isRunning():
self.cleanup()
def process_image(self, device: str, image: BECImageItem, data: np.ndarray):
"""
Process the image data.
Args:
device(str): The name of the device - image_id of image.
image(np.ndarray): The image data to be processed.
data(np.ndarray): The image data to be processed.
Returns:
np.ndarray: The processed image data.
"""
processing_config = image.config.processing
self.processor.set_config(processing_config)
if self.use_threading:
self._create_thread_worker(device, data)
else:
data = self.processor.process_image(data)
self.update_image(device, data)
self.update_vrange(device, self.processor.config.stats)
@pyqtSlot(dict)
def on_image_update(self, msg: dict):
"""
@@ -494,8 +453,14 @@ class BECImageShow(BECPlotBase):
"""
data = msg["data"]
device = msg["device"]
image = self._images["device_monitor"][device]
self.process_image(device, image, data)
image_to_update = self._images["device_monitor"][device]
processing_config = image_to_update.config.processing
self.processor.set_config(processing_config)
if self.use_threading:
self._create_thread_worker(device, data)
else:
data = self.processor.process_image(data)
self.update_image(device, data)
@pyqtSlot(str, np.ndarray)
def update_image(self, device: str, data: np.ndarray):
@@ -509,27 +474,6 @@ class BECImageShow(BECPlotBase):
image_to_update = self._images["device_monitor"][device]
image_to_update.updateImage(data, autoLevels=image_to_update.config.autorange)
@pyqtSlot(str, ImageStats)
def update_vrange(self, device: str, stats: ImageStats):
"""
Update the scaling of the image.
Args:
stats(ImageStats): The statistics of the image.
"""
image_to_update = self._images["device_monitor"][device]
if image_to_update.config.autorange:
image_to_update.auto_update_vrange(stats)
def refresh_image(self):
"""
Refresh the image.
"""
for source, images in self._images.items():
for image_id, image in images.items():
data = image.get_data()
self.process_image(image_id, image, data)
def _connect_device_monitor(self, monitor: str):
"""
Connect to the device monitor.
@@ -542,18 +486,16 @@ class BECImageShow(BECPlotBase):
previous_monitor = image_item.config.monitor
except AttributeError:
previous_monitor = None
if previous_monitor and image_item.connected is True:
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor(previous_monitor)
)
image_item.connected = False
if monitor and image_item.connected is False:
self.entry_validator.validate_monitor(monitor)
self.bec_dispatcher.connect_slot(
self.on_image_update, MessageEndpoints.device_monitor(monitor)
)
image_item.set_monitor(monitor)
image_item.connected = True
if previous_monitor != monitor:
if previous_monitor:
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor(previous_monitor)
)
if monitor:
self.bec_dispatcher.connect_slot(
self.on_image_update, MessageEndpoints.device_monitor(monitor)
)
image_item.set_monitor(monitor)
def _add_image_object(
self, source: str, name: str, config: ImageItemConfig, data=None
@@ -562,8 +504,6 @@ class BECImageShow(BECPlotBase):
image = BECImageItem(config=config, parent_image=self)
self.plot_item.addItem(image)
self._images[source][name] = image
if source == "device_monitor":
self._connect_device_monitor(config.monitor)
self.config.images[name] = config
if data is not None:
image.setImage(data)
@@ -588,6 +528,23 @@ class BECImageShow(BECPlotBase):
return True
return False
def _validate_monitor(self, monitor: str, validate_bec: bool = True):
"""
Validate the monitor name.
Args:
monitor(str): The name of the monitor.
validate_bec(bool): Whether to validate the monitor name with BEC.
Returns:
bool: True if the monitor name is valid, False otherwise.
"""
if not monitor or monitor == "":
return False
if validate_bec:
return monitor in self.dev
return True
def cleanup(self):
"""
Clean up the widget.

View File

@@ -7,7 +7,7 @@ import pyqtgraph as pg
from pydantic import Field
from bec_widgets.utils import BECConnector, ConnectionConfig
from bec_widgets.widgets.figure.plots.image.image_processor import ImageStats, ProcessingConfig
from bec_widgets.widgets.figure.plots.image.image_processor import ProcessingConfig
if TYPE_CHECKING:
from bec_widgets.widgets.figure.plots.image.image import BECImageShow
@@ -20,16 +20,13 @@ class ImageItemConfig(ConnectionConfig):
color_map: Optional[str] = Field("magma", description="The color map of the image.")
downsample: Optional[bool] = Field(True, description="Whether to downsample the image.")
opacity: Optional[float] = Field(1.0, description="The opacity of the image.")
vrange: Optional[tuple[float | int, float | int]] = Field(
vrange: Optional[tuple[int, int]] = Field(
None, description="The range of the color bar. If None, the range is automatically set."
)
color_bar: Optional[Literal["simple", "full"]] = Field(
"simple", description="The type of the color bar."
)
autorange: Optional[bool] = Field(True, description="Whether to autorange the color bar.")
autorange_mode: Optional[Literal["max", "mean"]] = Field(
"mean", description="Whether to use the mean of the image for autoscaling."
)
processing: ProcessingConfig = Field(
default_factory=ProcessingConfig, description="The post processing of the image."
)
@@ -37,8 +34,8 @@ class ImageItemConfig(ConnectionConfig):
class BECImageItem(BECConnector, pg.ImageItem):
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"rpc_id",
"config_dict",
"set",
"set_fft",
"set_log",
@@ -46,7 +43,6 @@ class BECImageItem(BECConnector, pg.ImageItem):
"set_transpose",
"set_opacity",
"set_autorange",
"set_autorange_mode",
"set_color_map",
"set_auto_downsample",
"set_monitor",
@@ -78,7 +74,6 @@ class BECImageItem(BECConnector, pg.ImageItem):
self.apply_config()
if kwargs:
self.set(**kwargs)
self.connected = False
def apply_config(self):
"""
@@ -106,7 +101,6 @@ class BECImageItem(BECConnector, pg.ImageItem):
- log
- rot
- transpose
- autorange_mode
"""
method_map = {
"downsample": self.set_auto_downsample,
@@ -118,7 +112,6 @@ class BECImageItem(BECConnector, pg.ImageItem):
"log": self.set_log,
"rot": self.set_rotation,
"transpose": self.set_transpose,
"autorange_mode": self.set_autorange_mode,
}
for key, value in kwargs.items():
if key in method_map:
@@ -182,18 +175,9 @@ class BECImageItem(BECConnector, pg.ImageItem):
autorange(bool): Whether to autorange the color bar.
"""
self.config.autorange = autorange
if self.color_bar and autorange:
if self.color_bar is not None:
self.color_bar.autoHistogramRange()
def set_autorange_mode(self, mode: Literal["max", "mean"] = "mean"):
"""
Set the autorange mode to scale the vrange of the color bar. Choose between min/max or mean +/- std.
Args:
mode(Literal["max","mean"]): Max for min/max or mean for mean +/- std.
"""
self.config.autorange_mode = mode
def set_color_map(self, cmap: str = "magma"):
"""
Set the color map of the image.
@@ -228,29 +212,7 @@ class BECImageItem(BECConnector, pg.ImageItem):
"""
self.config.monitor = monitor
def auto_update_vrange(self, stats: ImageStats) -> None:
"""Auto update of the vrange base on the stats of the image.
Args:
stats(ImageStats): The stats of the image.
"""
fumble_factor = 2
if self.config.autorange_mode == "mean":
vmin = max(stats.mean - fumble_factor * stats.std, 0)
vmax = stats.mean + fumble_factor * stats.std
self.set_vrange(vmin, vmax, change_autorange=False)
return
if self.config.autorange_mode == "max":
self.set_vrange(max(stats.minimum, 0), stats.maximum, change_autorange=False)
return
def set_vrange(
self,
vmin: float = None,
vmax: float = None,
vrange: tuple[float, float] = None,
change_autorange: bool = True,
):
def set_vrange(self, vmin: float = None, vmax: float = None, vrange: tuple[int, int] = None):
"""
Set the range of the color bar.
@@ -262,13 +224,11 @@ class BECImageItem(BECConnector, pg.ImageItem):
vmin, vmax = vrange
self.setLevels([vmin, vmax])
self.config.vrange = (vmin, vmax)
if change_autorange:
self.config.autorange = False
self.config.autorange = False
if self.color_bar is not None:
if self.config.color_bar == "simple":
self.color_bar.setLevels(low=vmin, high=vmax)
elif self.config.color_bar == "full":
# pylint: disable=unexpected-keyword-arg
self.color_bar.setLevels(min=vmin, max=vmax)
self.color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import numpy as np
@@ -8,16 +7,6 @@ from pydantic import BaseModel, Field
from qtpy.QtCore import QObject, Signal, Slot
@dataclass
class ImageStats:
"""Container to store stats of an image."""
maximum: float
minimum: float
mean: float
std: float
class ProcessingConfig(BaseModel):
fft: Optional[bool] = Field(False, description="Whether to perform FFT on the monitor data.")
log: Optional[bool] = Field(False, description="Whether to perform log on the monitor data.")
@@ -31,10 +20,6 @@ class ProcessingConfig(BaseModel):
None, description="The rotation angle of the monitor data before displaying."
)
model_config: dict = {"validate_assignment": True}
stats: ImageStats = Field(
ImageStats(maximum=0, minimum=0, mean=0, std=0),
description="The statistics of the image data.",
)
class ImageProcessor:
@@ -112,18 +97,6 @@ class ImageProcessor:
# def center_of_mass(self, data: np.ndarray) -> tuple: # TODO check functionality
# return np.unravel_index(np.argmax(data), data.shape)
def update_image_stats(self, data: np.ndarray) -> None:
"""Get the statistics of the image data.
Args:
data(np.ndarray): The image data.
"""
self.config.stats.maximum = np.max(data)
self.config.stats.minimum = np.min(data)
self.config.stats.mean = np.mean(data)
self.config.stats.std = np.std(data)
def process_image(self, data: np.ndarray) -> np.ndarray:
"""
Process the data according to the configuration.
@@ -142,7 +115,6 @@ class ImageProcessor:
data = self.transpose(data)
if self.config.log:
data = self.log(data)
self.update_image_stats(data)
return data
@@ -152,7 +124,6 @@ class ProcessorWorker(QObject):
"""
processed = Signal(str, np.ndarray)
stats = Signal(str, ImageStats)
stopRequested = Signal()
finished = Signal()
@@ -176,7 +147,6 @@ class ProcessorWorker(QObject):
self._isRunning = False
if not self._isRunning:
self.processed.emit(device, processed_image)
self.stats.emit(self.processor.config.stats)
self.finished.emit()
def stop(self):

View File

@@ -6,51 +6,37 @@ from typing import Optional, Union
import numpy as np
import pyqtgraph as pg
from bec_lib.endpoints import MessageEndpoints
from pydantic import Field, ValidationError, field_validator
from pydantic_core import PydanticCustomError
from pydantic import Field
from qtpy import QtCore, QtGui
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import Colors, EntryValidator
from bec_widgets.utils import EntryValidator
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.figure.plots.waveform.waveform import Signal, SignalData
class MotorMapConfig(SubplotConfig):
signals: Optional[Signal] = Field(None, description="Signals of the motor map")
color: Optional[str | tuple] = Field(
(255, 255, 255, 255), description="The color of the last point of current position."
)
color_map: Optional[str] = Field(
"Greys", description="Color scheme of the motor position gradient."
) # TODO decide if useful for anything, or just keep GREYS always
scatter_size: Optional[int] = Field(5, description="Size of the scatter points.")
max_points: Optional[int] = Field(5000, description="Maximum number of points to display.")
max_points: Optional[int] = Field(1000, description="Maximum number of points to display.")
num_dim_points: Optional[int] = Field(
100,
description="Number of points to dim before the color remains same for older recorded position.",
)
precision: Optional[int] = Field(2, description="Decimal precision of the motor position.")
background_value: Optional[int] = Field(
25, description="Background value of the motor map. Has to be between 0 and 255."
)
model_config: dict = {"validate_assignment": True}
_validate_color = field_validator("color")(Colors.validate_color)
@field_validator("background_value")
def validate_background_value(cls, value):
if not 0 <= value <= 255:
raise PydanticCustomError(
"wrong_value", f"'{value}' hs to be between 0 and 255.", {"wrong_value": value}
)
return value
25, description="Background value of the motor map."
) # TODO can be percentage from 255 calculated
class BECMotorMap(BECPlotBase):
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"config_dict",
"change_motors",
"set_max_points",
"set_precision",
@@ -58,8 +44,6 @@ class BECMotorMap(BECPlotBase):
"set_background_value",
"set_scatter_size",
"get_data",
"remove",
"reset_history",
]
# QT Signals
@@ -83,45 +67,31 @@ class BECMotorMap(BECPlotBase):
self.get_bec_shortcuts()
self.entry_validator = EntryValidator(self.dev)
# connect update signal to update plot
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self._update_plot
)
self.apply_config(self.config)
def apply_config(self, config: dict | MotorMapConfig):
"""
Apply the config to the motor map.
Args:
config(dict|MotorMapConfig): Config to be applied.
"""
if isinstance(config, dict):
try:
config = MotorMapConfig(**config)
except ValidationError as e:
print(f"Error in applying config: {e}")
return
self.config = config
self.plot_item.clear()
self.motor_x = None
self.motor_y = None
self.database_buffer = {"x": [], "y": []}
self.plot_components = defaultdict(dict) # container for plot components
self.apply_axis_config()
# connect update signal to update plot
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self._update_plot
)
if self.config.signals is not None:
self.change_motors(
motor_x=self.config.signals.x.name,
motor_y=self.config.signals.y.name,
motor_x_entry=self.config.signals.x.entry,
motor_y_entry=self.config.signals.y.entry,
)
# TODO decide if needed to implement, maybe there will be no children widgets for motormap for now...
# def find_widget_by_id(self, item_id: str) -> BECCurve:
# """
# Find the curve by its ID.
# Args:
# item_id(str): ID of the curve.
#
# Returns:
# BECCurve: The curve object.
# """
# for curve in self.plot_item.curves:
# if curve.gui_id == item_id:
# return curve
@Slot(str, str, str, str, bool)
@pyqtSlot(str, str, str, str, bool)
def change_motors(
self,
motor_x: str,
@@ -140,8 +110,6 @@ class BECMotorMap(BECPlotBase):
motor_y_entry(str): Motor entry for the Y axis.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
"""
self.plot_item.clear()
motor_x_entry, motor_y_entry = self._validate_signal_entries(
motor_x, motor_y, motor_x_entry, motor_y_entry, validate_bec
)
@@ -159,42 +127,19 @@ class BECMotorMap(BECPlotBase):
# reconnect the signals
self._connect_motor_to_slots()
self.database_buffer = {"x": [], "y": []}
# Redraw the motor map
self._make_motor_map()
def get_data(self) -> dict:
"""
Get the data of the motor map.
Returns:
dict: Data of the motor map.
"""
data = {"x": self.database_buffer["x"], "y": self.database_buffer["y"]}
return data
def reset_history(self):
"""
Reset the history of the motor map.
"""
self.database_buffer["x"] = [self.database_buffer["x"][-1]]
self.database_buffer["y"] = [self.database_buffer["y"][-1]]
self.update_signal.emit()
def set_color(self, color: str | tuple):
"""
Set color of the motor trace.
Args:
color(str|tuple): Color of the motor trace. Can be HEX(str) or RGBA(tuple).
"""
if isinstance(color, str):
color = Colors.validate_color(color)
color = Colors.hex_to_rgba(color, 255)
self.config.color = color
self.update_signal.emit()
# TODO setup all visual properties
def set_max_points(self, max_points: int) -> None:
"""
Set the maximum number of points to display.
@@ -203,7 +148,6 @@ class BECMotorMap(BECPlotBase):
max_points(int): Maximum number of points to display.
"""
self.config.max_points = max_points
self.update_signal.emit()
def set_precision(self, precision: int) -> None:
"""
@@ -213,7 +157,6 @@ class BECMotorMap(BECPlotBase):
precision(int): Decimal precision of the motor position.
"""
self.config.precision = precision
self.update_signal.emit()
def set_num_dim_points(self, num_dim_points: int) -> None:
"""
@@ -223,7 +166,6 @@ class BECMotorMap(BECPlotBase):
num_dim_points(int): Number of dim points.
"""
self.config.num_dim_points = num_dim_points
self.update_signal.emit()
def set_background_value(self, background_value: int) -> None:
"""
@@ -233,7 +175,6 @@ class BECMotorMap(BECPlotBase):
background_value(int): Background value of the motor map.
"""
self.config.background_value = background_value
self._swap_limit_map()
def set_scatter_size(self, scatter_size: int) -> None:
"""
@@ -243,7 +184,6 @@ class BECMotorMap(BECPlotBase):
scatter_size(int): Size of the scatter points.
"""
self.config.scatter_size = scatter_size
self.update_signal.emit()
def _disconnect_current_motors(self):
"""Disconnect the current motors from the slots."""
@@ -268,15 +208,6 @@ class BECMotorMap(BECPlotBase):
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoints)
def _swap_limit_map(self):
"""Swap the limit map."""
self.plot_item.removeItem(self.plot_components["limit_map"])
self.plot_components["limit_map"] = self._make_limit_map(
self.config.signals.x.limits, self.config.signals.y.limits
)
self.plot_components["limit_map"].setZValue(-1)
self.plot_item.addItem(self.plot_components["limit_map"])
def _make_motor_map(self):
"""
Create the motor map plot.
@@ -316,8 +247,6 @@ class BECMotorMap(BECPlotBase):
# Set default labels for the plot
self.set(x_label=f"Motor X ({self.motor_x})", y_label=f"Motor Y ({self.motor_y})")
self.update_signal.emit()
def _add_coordinantes_crosshair(self, x: float, y: float) -> None:
"""
Add crosshair to the plot to highlight the current position.
@@ -440,34 +369,21 @@ class BECMotorMap(BECPlotBase):
print(f"The device '{motor}' does not have defined limits.")
return None
@Slot()
def _update_plot(self):
"""Update the motor map plot."""
# If the number of points exceeds max_points, delete the oldest points
if len(self.database_buffer["x"]) > self.config.max_points:
self.database_buffer["x"] = self.database_buffer["x"][-self.config.max_points :]
self.database_buffer["y"] = self.database_buffer["y"][-self.config.max_points :]
x = self.database_buffer["x"]
y = self.database_buffer["y"]
# Setup gradient brush for history
brushes = [pg.mkBrush(50, 50, 50, 255)] * len(x)
# RGB color
r, g, b, a = self.config.color
# Calculate the decrement step based on self.num_dim_points
num_dim_points = self.config.num_dim_points
decrement_step = (255 - 50) / num_dim_points
for i in range(1, min(num_dim_points + 1, len(x) + 1)):
brightness = max(60, 255 - decrement_step * (i - 1))
dim_r = int(r * (brightness / 255))
dim_g = int(g * (brightness / 255))
dim_b = int(b * (brightness / 255))
brushes[-i] = pg.mkBrush(dim_r, dim_g, dim_b, a)
brushes[-1] = pg.mkBrush(r, g, b, a) # Newest point is always full brightness
brushes[-i] = pg.mkBrush(brightness, brightness, brightness, 255)
brushes[-1] = pg.mkBrush(255, 255, 255, 255) # Newest point is always full brightness
scatter_size = self.config.scatter_size
# Update the scatter plot
@@ -490,7 +406,7 @@ class BECMotorMap(BECPlotBase):
f"Motor position: ({round(float(current_x),precision)}, {round(float(current_y),precision)})"
)
@Slot(dict)
@pyqtSlot(dict)
def on_device_readback(self, msg: dict) -> None:
"""
Update the motor map plot with the new motor position.

View File

@@ -5,8 +5,6 @@ from typing import Literal, Optional
import numpy as np
import pyqtgraph as pg
from pydantic import BaseModel, Field
from qtpy import QT_VERSION
from qtpy.QtGui import QFont, QFontDatabase, QFontInfo
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import BECConnector, ConnectionConfig
@@ -14,14 +12,8 @@ from bec_widgets.utils import BECConnector, ConnectionConfig
class AxisConfig(BaseModel):
title: Optional[str] = Field(None, description="The title of the axes.")
title_size: Optional[int] = Field(None, description="The font size of the title.")
x_label: Optional[str] = Field(None, description="The label for the x-axis.")
x_label_size: Optional[int] = Field(None, description="The font size of the x-axis label.")
y_label: Optional[str] = Field(None, description="The label for the y-axis.")
y_label_size: Optional[int] = Field(None, description="The font size of the y-axis label.")
legend_label_size: Optional[int] = Field(
None, description="The font size of the legend labels."
)
x_scale: Literal["linear", "log"] = Field("linear", description="The scale of the x-axis.")
y_scale: Literal["linear", "log"] = Field("linear", description="The scale of the y-axis.")
x_lim: Optional[tuple] = Field(None, description="The limits of the x-axis.")
@@ -46,7 +38,7 @@ class SubplotConfig(ConnectionConfig):
class BECPlotBase(BECConnector, pg.GraphicsLayout):
USER_ACCESS = [
"_config_dict",
"config_dict",
"set",
"set_title",
"set_x_label",
@@ -58,7 +50,6 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
"set_grid",
"lock_aspect_ratio",
"remove",
"set_legend_label_size",
]
def __init__(
@@ -94,7 +85,6 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
# Mapping of keywords to setter methods
method_map = {
@@ -105,7 +95,6 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
"y_scale": self.set_y_scale,
"x_lim": self.set_x_lim,
"y_lim": self.set_y_lim,
"legend_label_size": self.set_legend_label_size,
}
for key, value in kwargs.items():
if key in method_map:
@@ -127,79 +116,34 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
self.set(**{k: v for k, v in config_mappings.items() if v is not None})
def set_legend_label_size(self, size: int = None):
"""
Set the font size of the legend.
Args:
size(int): Font size of the legend.
"""
if not self.plot_item.legend:
return
if self.config.axis.legend_label_size or size:
if size:
self.config.axis.legend_label_size = size
scale = (
size / 9
) # 9 is the default font size of the legend, so we always scale it against 9
self.plot_item.legend.setScale(scale)
def get_text_color(self):
return "#FFF" if self.figure.config.theme == "dark" else "#000"
def set_title(self, title: str, size: int = None):
def set_title(self, title: str):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
size(int): Font size of the title.
"""
if self.config.axis.title_size or size:
if size:
self.config.axis.title_size = size
style = {"color": self.get_text_color(), "size": f"{self.config.axis.title_size}pt"}
else:
style = {}
self.plot_item.setTitle(title, **style)
self.plot_item.setTitle(title)
self.config.axis.title = title
def set_x_label(self, label: str, size: int = None):
def set_x_label(self, label: str):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
size(int): Font size of the label.
"""
if self.config.axis.x_label_size or size:
if size:
self.config.axis.x_label_size = size
style = {
"color": self.get_text_color(),
"font-size": f"{self.config.axis.x_label_size}pt",
}
else:
style = {}
self.plot_item.setLabel("bottom", label, **style)
self.plot_item.setLabel("bottom", label)
self.config.axis.x_label = label
def set_y_label(self, label: str, size: int = None):
def set_y_label(self, label: str):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
size(int): Font size of the label.
"""
if self.config.axis.y_label_size or size:
if size:
self.config.axis.y_label_size = size
color = self.get_text_color()
style = {"color": color, "font-size": f"{self.config.axis.y_label_size}pt"}
else:
style = {}
self.plot_item.setLabel("left", label, **style)
self.plot_item.setLabel("left", label)
self.config.axis.y_label = label
def set_x_scale(self, scale: Literal["linear", "log"] = "linear"):

View File

@@ -1,12 +1,10 @@
from __future__ import annotations
import time
from collections import defaultdict
from typing import Any, Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.scan_data import ScanData
from pydantic import Field, ValidationError
@@ -35,15 +33,15 @@ class Waveform1DConfig(SubplotConfig):
class BECWaveform(BECPlotBase):
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"rpc_id",
"config_dict",
"plot",
"add_dap",
"get_dap_params",
"remove_curve",
"scan_history",
"curves",
"get_curve",
"get_curve_config",
"apply_config",
"get_all_data",
"set",
"set_title",
@@ -56,10 +54,8 @@ class BECWaveform(BECPlotBase):
"set_grid",
"lock_aspect_ratio",
"remove",
"set_legend_label_size",
]
scan_signal_update = pyqtSignal()
dap_params_update = pyqtSignal(dict)
def __init__(
self,
@@ -76,7 +72,6 @@ class BECWaveform(BECPlotBase):
)
self._curves_data = defaultdict(dict)
self.old_scan_id = None
self.scan_id = None
# Scan segment update proxy
@@ -84,9 +79,6 @@ class BECWaveform(BECPlotBase):
self.scan_signal_update, rateLimit=25, slot=self._update_scan_segment_plot
)
self.proxy_update_dap = pg.SignalProxy(
self.scan_signal_update, rateLimit=25, slot=self.refresh_dap
)
# Get bec shortcuts dev, scans, queue, scan_storage, dap
self.get_bec_shortcuts()
@@ -220,7 +212,6 @@ class BECWaveform(BECPlotBase):
color_map_z: str | None = "plasma",
label: str | None = None,
validate: bool = True,
dap: str | None = None, # TODO add dap custom curve wrapper
) -> BECCurve:
"""
Plot a curve to the plot widget.
@@ -237,7 +228,6 @@ class BECWaveform(BECPlotBase):
color_map_z(str): The color map to use for the z-axis.
label(str): The label of the curve.
validate(bool): If True, validate the device names and entries.
dap(str): The dap model to use for the curve. If not specified, none will be added.
Returns:
BECCurve: The curve object.
@@ -246,8 +236,6 @@ class BECWaveform(BECPlotBase):
if x is not None and y is not None:
return self.add_curve_custom(x=x, y=y, label=label, color=color)
else:
if dap:
self.add_dap(x_name=x_name, y_name=y_name, dap=dap)
return self.add_curve_scan(
x_name=x_name,
y_name=y_name,
@@ -267,7 +255,6 @@ class BECWaveform(BECPlotBase):
y: list | np.ndarray,
label: str = None,
color: str = None,
curve_source: str = "custom",
**kwargs,
) -> BECCurve:
"""
@@ -278,13 +265,12 @@ class BECWaveform(BECPlotBase):
y(list|np.ndarray): Y data of the curve.
label(str, optional): Label of the curve. Defaults to None.
color(str, optional): Color of the curve. Defaults to None.
curve_source(str, optional): Tag for source of the curve. Defaults to "custom".
**kwargs: Additional keyword arguments for the curve configuration.
Returns:
BECCurve: The curve object.
"""
curve_source = curve_source
curve_source = "custom"
curve_id = label or f"Curve {len(self.plot_item.curves) + 1}"
curve_exits = self._check_curve_id(curve_id, self._curves_data)
@@ -327,12 +313,10 @@ class BECWaveform(BECPlotBase):
color_map_z: Optional[str] = "plasma",
label: Optional[str] = None,
validate_bec: bool = True,
source: str = "scan_segment",
dap: Optional[str] = None,
**kwargs,
) -> BECCurve:
"""
Add a curve to the plot widget from the scan segment. #TODO adapt docs to DAP
Add a curve to the plot widget from the scan segment.
Args:
x_name(str): Name of the x signal.
@@ -350,7 +334,7 @@ class BECWaveform(BECPlotBase):
BECCurve: The curve object.
"""
# Check if curve already exists
curve_source = source
curve_source = "scan_segment"
# Get entry if not provided and validate
x_entry, y_entry, z_entry = self._validate_signal_entries(
@@ -386,74 +370,12 @@ class BECWaveform(BECPlotBase):
x=SignalData(name=x_name, entry=x_entry),
y=SignalData(name=y_name, entry=y_entry),
z=SignalData(name=z_name, entry=z_entry) if z_name else None,
dap=dap,
),
**kwargs,
)
curve = self._add_curve_object(name=label, source=curve_source, config=curve_config)
return curve
def add_dap(
self,
x_name: str,
y_name: str,
x_entry: Optional[str] = None,
y_entry: Optional[str] = None,
color: Optional[str] = None,
dap: str = "GaussianModel",
**kwargs,
) -> BECCurve:
"""
Add LMFIT dap model curve to the plot widget.
Args:
x_name(str): Name of the x signal.
x_entry(str): Entry of the x signal.
y_name(str): Name of the y signal.
y_entry(str): Entry of the y signal.
color(str, optional): Color of the curve. Defaults to None.
color_map_z(str): The color map to use for the z-axis.
label(str, optional): Label of the curve. Defaults to None.
dap(str): The dap model to use for the curve.
**kwargs: Additional keyword arguments for the curve configuration.
Returns:
BECCurve: The curve object.
"""
x_entry, y_entry, _ = self._validate_signal_entries(
x_name, y_name, None, x_entry, y_entry, None
)
label = f"{y_name}-{y_entry}-{dap}"
curve = self.add_curve_scan(
x_name=x_name,
y_name=y_name,
x_entry=x_entry,
y_entry=y_entry,
color=color,
label=label,
source="DAP",
dap=dap,
pen_style="dash",
symbol="star",
**kwargs,
)
self.setup_dap(self.old_scan_id, self.scan_id)
self.refresh_dap()
return curve
def get_dap_params(self) -> dict:
"""
Get the DAP parameters of all DAP curves.
Returns:
dict: DAP parameters of all DAP curves.
"""
params = {}
for curve_id, curve in self._curves_data["DAP"].items():
params[curve_id] = curve.dap_params
return params
def _add_curve_object(
self,
name: str,
@@ -479,7 +401,6 @@ class BECWaveform(BECPlotBase):
self.config.curves[name] = curve.config
if data is not None:
curve.setData(data[0], data[1])
self.set_legend_label_size()
return curve
def _validate_signal_entries(
@@ -605,75 +526,13 @@ class BECWaveform(BECPlotBase):
return
if current_scan_id != self.scan_id:
self.old_scan_id = self.scan_id
self.scan_id = current_scan_id
self.scan_segment_data = self.queue.scan_storage.find_scan_by_ID(
self.scan_id
) # TODO do scan access through BECFigure
self.setup_dap(self.old_scan_id, self.scan_id)
self.scan_signal_update.emit()
def setup_dap(self, old_scan_id, new_scan_id):
"""
Setup DAP for the new scan.
Args:
old_scan_id(str): old_scan_id, used to disconnect the previous dispatcher connection.
new_scan_id(str): new_scan_id, used to connect the new dispatcher connection.
"""
self.bec_dispatcher.disconnect_slot(
self.update_dap, MessageEndpoints.dap_response(old_scan_id)
)
if len(self._curves_data["DAP"]) > 0:
self.bec_dispatcher.connect_slot(
self.update_dap, MessageEndpoints.dap_response(new_scan_id)
)
def refresh_dap(self):
"""
Refresh the DAP curves with the latest data from the DAP model MessageEndpoints.dap_response().
"""
for curve_id, curve in self._curves_data["DAP"].items():
x_name = curve.config.signals.x.name
y_name = curve.config.signals.y.name
x_entry = curve.config.signals.x.entry
y_entry = curve.config.signals.y.entry
model_name = curve.config.signals.dap
model = getattr(self.dap, model_name)
msg = messages.DAPRequestMessage(
dap_cls="LmfitService1D",
dap_type="on_demand",
config={
"args": [self.scan_id, x_name, x_entry, y_name, y_entry],
"kwargs": {},
"class_args": model._plugin_info["class_args"],
"class_kwargs": model._plugin_info["class_kwargs"],
},
metadata={"RID": self.scan_id},
)
self.client.connector.set_and_publish(MessageEndpoints.dap_request(), msg)
@pyqtSlot(dict, dict)
def update_dap(self, msg, metadata):
self.msg = msg
scan_id, x_name, x_entry, y_name, y_entry = msg["dap_request"].content["config"]["args"]
model = msg["dap_request"].content["config"]["class_kwargs"]["model"]
curve_id_request = f"{y_name}-{y_entry}-{model}"
for curve_id, curve in self._curves_data["DAP"].items():
if curve_id == curve_id_request:
if msg["data"] is not None:
x = msg["data"][0]["x"]
y = msg["data"][0]["y"]
curve.setData(x, y)
curve.dap_params = msg["data"][1]["fit_parameters"]
self.dap_params_update.emit(curve.dap_params)
break
def _update_scan_segment_plot(self):
"""Update the plot with the data from the scan segment."""
data = self.scan_segment_data.data
@@ -703,15 +562,14 @@ class BECWaveform(BECPlotBase):
data_y = data[y_name][y_entry].val
if curve.config.signals.z:
data_z = data[z_name][z_entry].val
color_z = self._make_z_gradient(data_z, curve.config.color_map_z)
color_z = self._make_z_gradient(
data_z, curve.config.color_map_z
) # TODO decide how to implement custom gradient
except TypeError:
continue
if data_z is not None and color_z is not None:
try:
curve.setData(x=data_x, y=data_y, symbolBrush=color_z)
except:
return
curve.setData(x=data_x, y=data_y, symbolBrush=color_z)
else:
curve.setData(data_x, data_y)
@@ -749,17 +607,13 @@ class BECWaveform(BECPlotBase):
if scan_index is not None and scan_id is not None:
raise ValueError("Only one of scan_id or scan_index can be provided.")
# Reset DAP connector
self.bec_dispatcher.disconnect_slot(
self.update_dap, MessageEndpoints.dap_response(self.scan_id)
)
if scan_index is not None:
self.scan_id = self.queue.scan_storage.storage[scan_index].scan_id
data = self.queue.scan_storage.find_scan_by_ID(self.scan_id).data
elif scan_id is not None:
self.scan_id = scan_id
data = self.queue.scan_storage.find_scan_by_ID(self.scan_id).data
self.setup_dap(self.old_scan_id, self.scan_id)
data = self.queue.scan_storage.find_scan_by_ID(self.scan_id).data
self._update_scan_curves(data)
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict | pd.DataFrame:
@@ -805,9 +659,6 @@ class BECWaveform(BECPlotBase):
def cleanup(self):
"""Cleanup the widget connection from BECDispatcher."""
self.bec_dispatcher.disconnect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
self.bec_dispatcher.disconnect_slot(
self.update_dap, MessageEndpoints.dap_response(self.scan_id)
)
for curve in self.curves:
curve.cleanup()
super().cleanup()

View File

@@ -31,7 +31,6 @@ class Signal(BaseModel):
x: SignalData # TODO maybe add metadata for config gui later
y: SignalData
z: Optional[SignalData] = None
dap: Optional[str] = None
model_config: dict = {"validate_assignment": True}
@@ -64,9 +63,8 @@ class CurveConfig(ConnectionConfig):
class BECCurve(BECConnector, pg.PlotDataItem):
USER_ACCESS = [
"remove",
"dap_params",
"_rpc_id",
"_config_dict",
"rpc_id",
"config_dict",
"set",
"set_data",
"set_color",
@@ -77,7 +75,6 @@ class BECCurve(BECConnector, pg.PlotDataItem):
"set_pen_width",
"set_pen_style",
"get_data",
"dap_params",
]
def __init__(
@@ -99,7 +96,6 @@ class BECCurve(BECConnector, pg.PlotDataItem):
self.parent_item = parent_item
self.apply_config()
self.dap_params = None
if kwargs:
self.set(**kwargs)
@@ -123,14 +119,6 @@ class BECCurve(BECConnector, pg.PlotDataItem):
self.setSymbolSize(self.config.symbol_size)
self.setSymbol(self.config.symbol)
@property
def dap_params(self):
return self._dap_params
@dap_params.setter
def dap_params(self, value):
self._dap_params = value
def set_data(self, x, y):
if self.config.source == "custom":
self.setData(x, y)
@@ -253,6 +241,5 @@ class BECCurve(BECConnector, pg.PlotDataItem):
def remove(self):
"""Remove the curve from the plot."""
# self.parent_item.removeItem(self)
self.parent_item.remove_curve(self.name())
self.parent_item.removeItem(self)
self.cleanup()

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF">
<path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M17 7h-4v2h4c1.65 0 3 1.35 3 3s-1.35 3-3 3h-4v2h4c2.76 0 5-2.24 5-5s-2.24-5-5-5zm-6 8H7c-1.65 0-3-1.35-3-3s1.35-3 3-3h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-2zm-3-4h8v2H8z"/>
</svg>

Before

Width:  |  Height:  |  Size: 341 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF">
<path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.25 2.52.77-1.28-3.52-2.09V8z"/>
</svg>

Before

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF">
<path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M19.43 12.98c.04-.32.07-.64.07-.98 0-.34-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.09-.16-.26-.25-.44-.25-.06 0-.12.01-.17.03l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.06-.02-.12-.03-.18-.03-.17 0-.34.09-.43.25l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98 0 .33.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.09.16.26.25.44.25.06 0 .12-.01.17-.03l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.06.02.12.03.18.03.17 0 .34-.09.43-.25l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zm-1.98-1.71c.04.31.05.52.05.73 0 .21-.02.43-.05.73l-.14 1.13.89.7 1.08.84-.7 1.21-1.27-.51-1.04-.42-.9.68c-.43.32-.84.56-1.25.73l-1.06.43-.16 1.13-.2 1.35h-1.4l-.19-1.35-.16-1.13-1.06-.43c-.43-.18-.83-.41-1.23-.71l-.91-.7-1.06.43-1.27.51-.7-1.21 1.08-.84.89-.7-.14-1.13c-.03-.31-.05-.54-.05-.74s.02-.43.05-.73l.14-1.13-.89-.7-1.08-.84.7-1.21 1.27.51 1.04.42.9-.68c.43-.32.84-.56 1.25-.73l1.06-.43.16-1.13.2-1.35h1.39l.19 1.35.16 1.13 1.06.43c.43.18.83.41 1.23.71l.91.7 1.06-.43 1.27-.51.7 1.21-1.07.85-.89.7.14 1.13zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1 +0,0 @@
{'files': ['motor_map_widget.py','motor_map_widget_plugin.py']}

View File

@@ -1,55 +0,0 @@
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from bec_widgets.widgets.motor_map.motor_map_widget import BECMotorMapWidget
DOM_XML = """
<ui language='c++'>
<widget class='BECMotorMapWidget' name='bec_motor_map_widget'>
</widget>
</ui>
"""
class BECMotorMapWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECMotorMapWidget(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Visualization Widgets"
def icon(self):
current_path = os.path.dirname(__file__)
icon_path = os.path.join(current_path, "assets", "motor_map.png")
return QIcon(icon_path)
def includeFile(self):
return "bec_motor_map_widget"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECMotorMapWidget"
def toolTip(self):
return "BECMotorMapWidget"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,73 +0,0 @@
import os
from qtpy.QtCore import Slot
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.widget_io import WidgetIO
class MotorMapSettings(QWidget):
def __init__(self, parent=None, target_widget: QWidget = None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
current_path = os.path.dirname(__file__)
self.ui = UILoader(self).loader(os.path.join(current_path, "motor_map_settings.ui"))
self.target_widget = target_widget
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.ui)
@Slot(dict)
def display_current_settings(self, config: dict):
WidgetIO.set_value(self.ui.max_points, config["max_points"])
WidgetIO.set_value(self.ui.trace_dim, config["num_dim_points"])
WidgetIO.set_value(self.ui.precision, config["precision"])
WidgetIO.set_value(self.ui.scatter_size, config["scatter_size"])
background_intensity = int((config["background_value"] / 255) * 100)
WidgetIO.set_value(self.ui.background_value, background_intensity)
color = config["color"]
self.ui.color.setColor(color)
@Slot()
def accept_changes(self):
max_points = WidgetIO.get_value(self.ui.max_points)
num_dim_points = WidgetIO.get_value(self.ui.trace_dim)
precision = WidgetIO.get_value(self.ui.precision)
scatter_size = WidgetIO.get_value(self.ui.scatter_size)
background_intensity = int(WidgetIO.get_value(self.ui.background_value) * 0.01 * 255)
color = self.ui.color.color().toTuple()
if self.target_widget is not None:
self.target_widget.set_max_points(max_points)
self.target_widget.set_num_dim_points(num_dim_points)
self.target_widget.set_precision(precision)
self.target_widget.set_scatter_size(scatter_size)
self.target_widget.set_background_value(background_intensity)
self.target_widget.set_color(color)
class MotorMapDialog(QDialog):
def __init__(self, parent=None, target_widget: QWidget = None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self.setModal(False)
self.setWindowTitle("Motor Map Settings")
self.target_widget = target_widget
self.widget = MotorMapSettings(target_widget=self.target_widget)
self.widget.display_current_settings(self.target_widget._config_dict)
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.widget)
self.layout.addWidget(self.button_box)
@Slot()
def accept(self):
self.widget.accept_changes()
super().accept()

View File

@@ -1,108 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>243</width>
<height>233</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="3" column="1">
<widget class="QSpinBox" name="scatter_size">
<property name="maximum">
<number>20</number>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="trace_label">
<property name="text">
<string>Trace Dim</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="precision_label">
<property name="text">
<string>Precision</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="background_label">
<property name="text">
<string>Background Intensity</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QSpinBox" name="precision">
<property name="maximum">
<number>15</number>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QSpinBox" name="background_value">
<property name="maximum">
<number>100</number>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="max_point_label">
<property name="text">
<string>Max Points</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="scatter_size_label">
<property name="text">
<string>Scatter Size</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="max_points">
<property name="maximum">
<number>10000</number>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="trace_dim">
<property name="maximum">
<number>1000</number>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="color_label">
<property name="text">
<string>Color</string>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="ColorButton" name="color"/>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ColorButton</class>
<extends>QPushButton</extends>
<header>color_button</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,59 +0,0 @@
import os
from qtpy.QtCore import QSize
from qtpy.QtGui import QAction, QIcon
from qtpy.QtWidgets import QHBoxLayout, QLabel, QWidget
from bec_widgets.widgets.device_inputs import DeviceComboBox
from bec_widgets.widgets.toolbar.toolbar import ToolBarAction
class DeviceSelectionAction(ToolBarAction):
def __init__(self, label: str):
self.label = label
self.device_combobox = DeviceComboBox(device_filter="Positioner")
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
def add_to_toolbar(self, toolbar, target):
widget = QWidget()
layout = QHBoxLayout(widget)
label = QLabel(f"{self.label}")
layout.addWidget(label)
layout.addWidget(self.device_combobox)
toolbar.addWidget(widget)
def set_combobox_style(self, color: str):
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
class ConnectAction(ToolBarAction):
def add_to_toolbar(self, toolbar, target):
current_path = os.path.dirname(__file__)
parent_path = os.path.dirname(current_path)
icon = QIcon()
icon.addFile(os.path.join(parent_path, "assets", "connection.svg"), size=QSize(20, 20))
self.action = QAction(icon, "Connect Motors", target)
toolbar.addAction(self.action)
class ResetHistoryAction(ToolBarAction):
def add_to_toolbar(self, toolbar, target):
current_path = os.path.dirname(__file__)
parent_path = os.path.dirname(current_path)
icon = QIcon()
icon.addFile(os.path.join(parent_path, "assets", "history.svg"), size=QSize(20, 20))
self.action = QAction(icon, "Reset Trace History", target)
toolbar.addAction(self.action)
class SettingsAction(ToolBarAction):
def add_to_toolbar(self, toolbar, target):
current_path = os.path.dirname(__file__)
parent_path = os.path.dirname(current_path)
icon = QIcon()
icon.addFile(os.path.join(parent_path, "assets", "settings.svg"), size=QSize(20, 20))
self.action = QAction(icon, "Open Configuration Dialog", target)
toolbar.addAction(self.action)

View File

@@ -1,235 +0,0 @@
from __future__ import annotations
import sys
from qtpy import PYSIDE6
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.figure.plots.motor_map.motor_map import MotorMapConfig
from bec_widgets.widgets.motor_map.motor_map_dialog.motor_map_settings import MotorMapDialog
from bec_widgets.widgets.motor_map.motor_map_dialog.motor_map_toolbar import (
ConnectAction,
DeviceSelectionAction,
ResetHistoryAction,
SettingsAction,
)
from bec_widgets.widgets.toolbar import ModularToolBar
class BECMotorMapWidget(BECConnector, QWidget):
USER_ACCESS = [
"change_motors",
"set_max_points",
"set_precision",
"set_num_dim_points",
"set_background_value",
"set_scatter_size",
"get_data",
"reset_history",
]
def __init__(
self,
parent: QWidget | None = None,
config: MotorMapConfig | None = None,
client=None,
gui_id: str | None = None,
) -> None:
if not PYSIDE6:
raise RuntimeError(
"PYSIDE6 is not available in the environment. This widget is compatible only with PySide6."
)
if config is None:
config = MotorMapConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = MotorMapConfig(**config)
super().__init__(client=client, gui_id=gui_id)
QWidget.__init__(self, parent)
self.layout = QVBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.fig = BECFigure()
self.toolbar = ModularToolBar(
actions={
"motor_x": DeviceSelectionAction("Motor X:"),
"motor_y": DeviceSelectionAction("Motor Y:"),
"connect": ConnectAction(),
"history": ResetHistoryAction(),
"config": SettingsAction(),
},
target_widget=self,
)
self.layout.addWidget(self.toolbar)
self.layout.addWidget(self.fig)
self.map = self.fig.motor_map()
self.map.apply_config(config)
self._hook_actions()
self.config = config
def _hook_actions(self):
self.toolbar.widgets["connect"].action.triggered.connect(self._action_motors)
self.toolbar.widgets["config"].action.triggered.connect(self.show_settings)
self.toolbar.widgets["history"].action.triggered.connect(self.reset_history)
if self.map.motor_x is None and self.map.motor_y is None:
self._enable_actions(False)
def _enable_actions(self, enable: bool):
self.toolbar.widgets["config"].action.setEnabled(enable)
self.toolbar.widgets["history"].action.setEnabled(enable)
def _action_motors(self):
toolbar_x = self.toolbar.widgets["motor_x"].device_combobox
toolbar_y = self.toolbar.widgets["motor_y"].device_combobox
motor_x = toolbar_x.currentText()
motor_y = toolbar_y.currentText()
self.change_motors(motor_x, motor_y, None, None, True)
toolbar_x.setStyleSheet("QComboBox {{ background-color: " "; }}")
toolbar_y.setStyleSheet("QComboBox {{ background-color: " "; }}")
def show_settings(self) -> None:
dialog = MotorMapDialog(self, target_widget=self)
dialog.exec()
###################################
# User Access Methods from MotorMap
###################################
def change_motors(
self,
motor_x: str,
motor_y: str,
motor_x_entry: str = None,
motor_y_entry: str = None,
validate_bec: bool = True,
) -> None:
"""
Change the active motors for the plot.
Args:
motor_x(str): Motor name for the X axis.
motor_y(str): Motor name for the Y axis.
motor_x_entry(str): Motor entry for the X axis.
motor_y_entry(str): Motor entry for the Y axis.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
"""
self.map.change_motors(motor_x, motor_y, motor_x_entry, motor_y_entry, validate_bec)
if self.map.motor_x is not None and self.map.motor_y is not None:
self._enable_actions(True)
toolbar_x = self.toolbar.widgets["motor_x"].device_combobox
toolbar_y = self.toolbar.widgets["motor_y"].device_combobox
if toolbar_x.currentText() != motor_x:
toolbar_x.setCurrentText(motor_x)
toolbar_x.setStyleSheet("QComboBox {{ background-color: " "; }}")
if toolbar_y.currentText() != motor_y:
toolbar_y.setCurrentText(motor_y)
toolbar_y.setStyleSheet("QComboBox {{ background-color: " "; }}")
def get_data(self) -> dict:
"""
Get the data of the motor map.
Returns:
dict: Data of the motor map.
"""
return self.map.get_data()
def reset_history(self) -> None:
"""
Reset the history of the motor map.
"""
self.map.reset_history()
def set_color(self, color: str | tuple):
"""
Set the color of the motor map.
Args:
color(str, tuple): Color to set.
"""
self.map.set_color(color)
def set_max_points(self, max_points: int) -> None:
"""
Set the maximum number of points to display on the motor map.
Args:
max_points(int): Maximum number of points to display.
"""
self.map.set_max_points(max_points)
def set_precision(self, precision: int) -> None:
"""
Set the precision of the motor map.
Args:
precision(int): Precision to set.
"""
self.map.set_precision(precision)
def set_num_dim_points(self, num_dim_points: int) -> None:
"""
Set the number of points to display on the motor map.
Args:
num_dim_points(int): Number of points to display.
"""
self.map.set_num_dim_points(num_dim_points)
def set_background_value(self, background_value: int) -> None:
"""
Set the background value of the motor map.
Args:
background_value(int): Background value of the motor map.
"""
self.map.set_background_value(background_value)
def set_scatter_size(self, scatter_size: int) -> None:
"""
Set the scatter size of the motor map.
Args:
scatter_size(int): Scatter size of the motor map.
"""
self.map.set_scatter_size(scatter_size)
def cleanup(self):
self.fig.cleanup()
self.toolbar.widgets["motor_x"].device_combobox.cleanup()
self.toolbar.widgets["motor_y"].device_combobox.cleanup()
return super().cleanup()
def closeEvent(self, event):
self.cleanup()
QWidget().closeEvent(event)
def main(): # pragma: no cover
if not PYSIDE6:
print(
"PYSIDE6 is not available in the environment. UI files with BEC custom widgets are runnable only with PySide6."
)
return
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = BECMotorMapWidget()
widget.show()
sys.exit(app.exec_())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,15 +0,0 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.motor_map.bec_motor_map_widget_plugin import BECMotorMapWidgetPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECMotorMapWidgetPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1 +0,0 @@
from .ring_progress_bar import RingProgressBar

View File

@@ -1,37 +1,53 @@
import qdarktheme
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLayout,
QLineEdit,
QPushButton,
QSizePolicy,
QSpinBox,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.buttons.stop_button.stop_button import StopButton
from bec_widgets.widgets.scan_control.scan_group_box import ScanGroupBox
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.widget_io import WidgetIO
class ScanControl(BECConnector, QWidget):
class ScanArgType:
DEVICE = "device"
FLOAT = "float"
INT = "int"
BOOL = "bool"
STR = "str"
def __init__(
self, parent=None, client=None, gui_id: str | None = None, allowed_scans: list | None = None
):
super().__init__(client=client, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
class ScanControl(QWidget):
WIDGET_HANDLER = {
ScanArgType.DEVICE: QLineEdit,
ScanArgType.FLOAT: QDoubleSpinBox,
ScanArgType.INT: QSpinBox,
ScanArgType.BOOL: QCheckBox,
ScanArgType.STR: QLineEdit,
}
def __init__(self, parent=None, client=None, allowed_scans=None):
super().__init__(parent)
# Client from BEC + shortcuts to device manager and scans
self.get_bec_shortcuts()
# Main layout
self.layout = QVBoxLayout(self)
self.arg_box = None
self.kwarg_boxes = []
self.expert_mode = False # TODO implement in the future versions
self.client = BECDispatcher().client if client is None else client
self.dev = self.client.device_manager.devices
self.scans = self.client.scans
# Scan list - allowed scans for the GUI
self.allowed_scans = allowed_scans
@@ -40,173 +56,389 @@ class ScanControl(BECConnector, QWidget):
self._init_UI()
def _init_UI(self):
"""
Initializes the UI of the scan control widget. Create the top box for scan selection and populate scans to main combobox.
"""
self.verticalLayout = QVBoxLayout(self)
# Scan selection group box
self.scan_selection_group = self.create_scan_selection_group()
self.scan_selection_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.layout.addWidget(self.scan_selection_group)
self.scan_selection_group = QGroupBox("Scan Selection", self)
self.scan_selection_layout = QVBoxLayout(self.scan_selection_group)
self.comboBox_scan_selection = QComboBox(self.scan_selection_group)
self.button_run_scan = QPushButton("Run Scan", self.scan_selection_group)
self.scan_selection_layout.addWidget(self.comboBox_scan_selection)
self.scan_selection_layout.addWidget(self.button_run_scan)
self.verticalLayout.addWidget(self.scan_selection_group)
# Scan control group box
self.scan_control_group = QGroupBox("Scan Control", self)
self.scan_control_layout = QVBoxLayout(self.scan_control_group)
self.verticalLayout.addWidget(self.scan_control_group)
# Kwargs layout - just placeholder
self.kwargs_layout = QGridLayout()
self.scan_control_layout.addLayout(self.kwargs_layout)
# 1st Separator
self.add_horizontal_separator(self.scan_control_layout)
# Buttons
self.button_layout = QHBoxLayout()
self.pushButton_add_bundle = QPushButton("Add Bundle", self.scan_control_group)
self.pushButton_add_bundle.clicked.connect(self.add_bundle)
self.pushButton_remove_bundle = QPushButton("Remove Bundle", self.scan_control_group)
self.pushButton_remove_bundle.clicked.connect(self.remove_bundle)
self.button_layout.addWidget(self.pushButton_add_bundle)
self.button_layout.addWidget(self.pushButton_remove_bundle)
self.scan_control_layout.addLayout(self.button_layout)
# 2nd Separator
self.add_horizontal_separator(self.scan_control_layout)
# Initialize the QTableWidget for args
self.args_table = QTableWidget()
self.args_table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
self.scan_control_layout.addWidget(self.args_table)
# Connect signals
self.comboBox_scan_selection.currentIndexChanged.connect(self.on_scan_selected)
self.button_run_scan.clicked.connect(self.run_scan)
self.button_add_bundle.clicked.connect(self.add_arg_bundle)
self.button_remove_bundle.clicked.connect(self.remove_arg_bundle)
# Initialize scan selection
self.populate_scans()
def create_scan_selection_group(self) -> QGroupBox:
def add_horizontal_separator(self, layout) -> None:
"""
Creates the scan selection group box with combobox to select the scan and start/stop button.
Adds a horizontal separator to the given layout
Returns:
QGroupBox: Group box containing the scan selection widgets.
Args:
layout: Layout to add the separator to
"""
scan_selection_group = QGroupBox("Scan Selection", self)
self.scan_selection_layout = QGridLayout(scan_selection_group)
self.comboBox_scan_selection = QComboBox(scan_selection_group)
# Run button
self.button_run_scan = QPushButton("Start", scan_selection_group)
self.button_run_scan.setStyleSheet("background-color: #559900; color: white")
# Stop button
self.button_stop_scan = StopButton(parent=scan_selection_group)
# Add bundle button
self.button_add_bundle = QPushButton("Add Bundle", scan_selection_group)
# Remove bundle button
self.button_remove_bundle = QPushButton("Remove Bundle", scan_selection_group)
self.scan_selection_layout.addWidget(self.comboBox_scan_selection, 0, 0, 1, 2)
self.scan_selection_layout.addWidget(self.button_run_scan, 1, 0)
self.scan_selection_layout.addWidget(self.button_stop_scan, 1, 1)
self.scan_selection_layout.addWidget(self.button_add_bundle, 2, 0)
self.scan_selection_layout.addWidget(self.button_remove_bundle, 2, 1)
return scan_selection_group
separator = QFrame(self.scan_control_group)
separator.setFrameShape(QFrame.HLine)
separator.setFrameShadow(QFrame.Sunken)
layout.addWidget(separator)
def populate_scans(self):
"""Populates the scan selection combo box with available scans from BEC session."""
self.available_scans = self.client.connector.get(
MessageEndpoints.available_scans()
).resource
"""Populates the scan selection combo box with available scans"""
self.available_scans = self.client.producer.get(MessageEndpoints.available_scans()).resource
if self.allowed_scans is None:
supported_scans = ["ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase"]
allowed_scans = [
scan_name
for scan_name, scan_info in self.available_scans.items()
if scan_info["base_class"] in supported_scans and len(scan_info["gui_config"]) > 0
]
allowed_scans = self.available_scans.keys()
else:
allowed_scans = self.allowed_scans
# TODO check parent class is ScanBase -> filter out the scans not relevant for GUI
self.comboBox_scan_selection.addItems(allowed_scans)
def on_scan_selected(self):
"""Callback for scan selection combo box"""
self.reset_layout()
selected_scan_name = self.comboBox_scan_selection.currentText()
selected_scan_info = self.available_scans.get(selected_scan_name, {})
gui_config = selected_scan_info.get("gui_config", {})
self.arg_group = gui_config.get("arg_group", None)
self.kwarg_groups = gui_config.get("kwarg_groups", None)
print(selected_scan_info) # TODO remove when widget will be more mature
# Generate kwargs input
self.generate_kwargs_input_fields(selected_scan_info)
if self.arg_box is None:
self.button_add_bundle.setEnabled(False)
self.button_remove_bundle.setEnabled(False)
# Args section
self.generate_args_input_fields(selected_scan_info)
if len(self.arg_group["arg_inputs"]) > 0:
self.button_add_bundle.setEnabled(True)
self.button_remove_bundle.setEnabled(True)
self.add_arg_group(self.arg_group)
if len(self.kwarg_groups) > 0:
self.add_kwargs_boxes(self.kwarg_groups)
self.update()
self.adjustSize()
def add_kwargs_boxes(self, groups: list):
def add_labels_to_layout(self, labels: list, grid_layout: QGridLayout) -> None:
"""
Adds the given gui_groups to the scan control layout.
Adds labels to the given grid layout as a separate row.
Args:
groups(list): List of dictionaries containing the gui_group information.
labels (list): List of label names to add.
grid_layout (QGridLayout): The grid layout to which labels will be added.
"""
for group in groups:
box = ScanGroupBox(box_type="kwargs", config=group)
box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.layout.addWidget(box)
self.kwarg_boxes.append(box)
row_index = grid_layout.rowCount() # Get the next available row
for column_index, label_name in enumerate(labels):
label = QLabel(label_name.capitalize(), self.scan_control_group)
# Add the label to the grid layout at the calculated row and current column
grid_layout.addWidget(label, row_index, column_index)
def add_arg_group(self, group: dict):
def add_labels_to_table(
self, labels: list, table: QTableWidget
) -> None: # TODO could be moved to BECTable
"""
Adds the given gui_groups to the scan control layout.
Adds labels to the given table widget as a header row.
Args:
labels(list): List of label names to add.
table(QTableWidget): The table widget to which labels will be added.
"""
self.arg_box = ScanGroupBox(box_type="args", config=group)
self.arg_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.layout.addWidget(self.arg_box)
table.setColumnCount(len(labels))
table.setHorizontalHeaderLabels(labels)
def add_arg_bundle(self):
self.arg_box.add_widget_bundle()
def generate_args_input_fields(self, scan_info: dict) -> None:
"""
Generates input fields for args.
def remove_arg_bundle(self):
self.arg_box.remove_widget_bundle()
Args:
scan_info(dict): Scan signature dictionary from BEC.
"""
def reset_layout(self):
"""Clears the scan control layout from GuiGroups and ArgGroups boxes."""
if self.arg_box is not None:
self.layout.removeWidget(self.arg_box)
self.arg_box.deleteLater()
self.arg_box = None
if self.kwarg_boxes != []:
self.remove_kwarg_boxes()
# Setup args table limits
self.set_args_table_limits(self.args_table, scan_info)
def remove_kwarg_boxes(self):
for box in self.kwarg_boxes:
self.layout.removeWidget(box)
box.deleteLater()
self.kwarg_boxes = []
# Get arg_input from selected scan
self.arg_input = scan_info.get("arg_input", {})
# Generate labels for table
self.add_labels_to_table(list(self.arg_input.keys()), self.args_table)
# add minimum number of args rows
if self.arg_size_min is not None:
for i in range(self.arg_size_min):
self.add_bundle()
def generate_kwargs_input_fields(self, scan_info: dict) -> None:
"""
Generates input fields for kwargs
Args:
scan_info(dict): Scan signature dictionary from BEC.
"""
# Create a new kwarg layout to replace the old one - this is necessary because otherwise row count is not reseted
self.clear_and_delete_layout(self.kwargs_layout)
self.kwargs_layout = self.create_new_grid_layout() # Create new grid layout
self.scan_control_layout.insertLayout(0, self.kwargs_layout)
# Get signature
signature = scan_info.get("signature", [])
# Extract kwargs from the converted signature
kwargs = [param["name"] for param in signature if param["kind"] == "KEYWORD_ONLY"]
# Add labels
self.add_labels_to_layout(kwargs, self.kwargs_layout)
# Add widgets
widgets = self.generate_widgets_from_signature(kwargs, signature)
self.add_widgets_row_to_layout(self.kwargs_layout, widgets)
def generate_widgets_from_signature(self, items: list, signature: dict = None) -> list:
"""
Generates widgets from the given list of items.
Args:
items(list): List of items to create widgets for.
signature(dict, optional): Scan signature dictionary from BEC.
Returns:
list: List of widgets created from the given items.
"""
widgets = [] # Initialize an empty list to hold the widgets
for item in items:
if signature:
# If a signature is provided, extract type and name from it
kwarg_info = next((info for info in signature if info["name"] == item), None)
if kwarg_info:
item_type = kwarg_info.get("annotation", "_empty")
item_name = item
else:
# If no signature is provided, assume the item is a tuple of (name, type)
item_name, item_type = item
widget_class = self.WIDGET_HANDLER.get(item_type, None)
if widget_class is None:
print(f"Unsupported annotation '{item_type}' for parameter '{item_name}'")
continue
# Instantiate the widget and set some properties if necessary
widget = widget_class()
# set high default range for spin boxes #TODO can be linked to motor/device limits from BEC
if isinstance(widget, (QSpinBox, QDoubleSpinBox)):
widget.setRange(-9999, 9999)
widget.setValue(0)
# Add the widget to the list
widgets.append(widget)
return widgets
def set_args_table_limits(self, table: QTableWidget, scan_info: dict) -> None:
# Get bundle info
arg_bundle_size = scan_info.get("arg_bundle_size", {})
self.arg_size_min = arg_bundle_size.get("min", 1)
self.arg_size_max = arg_bundle_size.get("max", None)
# Clear the previous input fields
table.setRowCount(0) # Wipe table
def add_widgets_row_to_layout(
self, grid_layout: QGridLayout, widgets: list, row_index: int = None
) -> None:
"""
Adds a row of widgets to the given grid layout.
Args:
grid_layout (QGridLayout): The grid layout to which widgets will be added.
items (list): List of parameter names to create widgets for.
row_index (int): The row index where the widgets should be added.
"""
# If row_index is not specified, add to the next available row
if row_index is None:
row_index = grid_layout.rowCount()
for column_index, widget in enumerate(widgets):
# Add the widget to the grid layout at the specified row and column
grid_layout.addWidget(widget, row_index, column_index)
def add_widgets_row_to_table(
self, table_widget: QTableWidget, widgets: list, row_index: int = None
) -> None:
"""
Adds a row of widgets to the given QTableWidget.
Args:
table_widget (QTableWidget): The table widget to which widgets will be added.
widgets (list): List of widgets to add to the table.
row_index (int): The row index where the widgets should be added. If None, add to the end.
"""
# If row_index is not specified, add to the end of the table
if row_index is None or row_index > table_widget.rowCount():
row_index = table_widget.rowCount()
if self.arg_size_max is not None: # ensure the max args size is not exceeded
if row_index >= self.arg_size_max:
return
table_widget.insertRow(row_index)
for column_index, widget in enumerate(widgets):
# If the widget is a subclass of QWidget, use setCellWidget
if issubclass(type(widget), QWidget):
table_widget.setCellWidget(row_index, column_index, widget)
else:
# Otherwise, assume it's a string or some other value that should be displayed as text
item = QTableWidgetItem(str(widget))
table_widget.setItem(row_index, column_index, item)
# Optionally, adjust the row height based on the content #TODO decide if needed
table_widget.setRowHeight(
row_index,
max(widget.sizeHint().height() for widget in widgets if isinstance(widget, QWidget)),
)
def remove_last_row_from_table(self, table_widget: QTableWidget) -> None:
"""
Removes the last row from the given QTableWidget until only one row is left.
Args:
table_widget (QTableWidget): The table widget from which the last row will be removed.
"""
row_count = table_widget.rowCount()
if (
row_count > self.arg_size_min
): # Check to ensure there is a minimum number of rows remaining
table_widget.removeRow(row_count - 1)
def create_new_grid_layout(self):
new_layout = QGridLayout()
# TODO maybe setup other layouts properties here?
return new_layout
def clear_and_delete_layout(self, layout: QLayout):
"""
Clears and deletes the given layout and all its child widgets.
Args:
layout(QLayout): Layout to clear and delete
"""
if layout is not None:
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
else:
sub_layout = item.layout()
if sub_layout:
self.clear_and_delete_layout(sub_layout)
layout.deleteLater()
def add_bundle(self) -> None:
"""Adds a new bundle to the scan control layout"""
# Get widgets used for particular scan and save them to be able to use for adding bundles
args_widgets = self.generate_widgets_from_signature(
self.arg_input.items()
) # TODO decide if make sense to put widget list into method parameters
# Add first widgets row to the table
self.add_widgets_row_to_table(self.args_table, args_widgets)
def remove_bundle(self) -> None:
"""Removes the last bundle from the scan control layout"""
self.remove_last_row_from_table(self.args_table)
def extract_kwargs_from_grid_row(self, grid_layout: QGridLayout, row: int) -> dict:
kwargs = {}
for column in range(grid_layout.columnCount()):
label_item = grid_layout.itemAtPosition(row, column)
if label_item is not None:
label_widget = label_item.widget()
if isinstance(label_widget, QLabel):
key = label_widget.text()
# The corresponding value widget is in the next row
value_item = grid_layout.itemAtPosition(row + 1, column)
if value_item is not None:
value_widget = value_item.widget()
# Use WidgetIO.get_value to extract the value
value = WidgetIO.get_value(value_widget)
kwargs[key] = value
return kwargs
def extract_args_from_table(self, table: QTableWidget) -> list:
"""
Extracts the arguments from the given table widget.
Args:
table(QTableWidget): Table widget from which to extract the arguments
"""
args = []
for row in range(table.rowCount()):
row_args = []
for column in range(table.columnCount()):
widget = table.cellWidget(row, column)
if widget:
if isinstance(widget, QLineEdit): # special case for QLineEdit for Devices
value = widget.text().lower()
if value in self.dev:
value = getattr(self.dev, value)
else:
raise ValueError(f"The device '{value}' is not recognized.")
else:
value = WidgetIO.get_value(widget)
row_args.append(value)
args.extend(row_args)
return args
def run_scan(self):
args = []
kwargs = {}
if self.arg_box is not None:
args = self.arg_box.get_parameters()
for box in self.kwarg_boxes:
box_kwargs = box.get_parameters()
kwargs.update(box_kwargs)
# Extract kwargs for the scan
kwargs = {
k.lower(): v
for k, v in self.extract_kwargs_from_grid_row(self.kwargs_layout, 1).items()
}
# Extract args from the table
args = self.extract_args_from_table(self.args_table)
# Convert args to lowercase if they are strings
args = [arg.lower() if isinstance(arg, str) else arg for arg in args]
# Execute the scan
scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText())
if callable(scan_function):
scan_function(*args, **kwargs)
def cleanup(self):
self.button_stop_scan.cleanup()
if self.arg_box:
for widget in self.arg_box.widgets:
if hasattr(widget, "cleanup"):
widget.cleanup()
for kwarg_box in self.kwarg_boxes:
for widget in kwarg_box.widgets:
if hasattr(widget, "cleanup"):
widget.cleanup()
super().cleanup()
def closeEvent(self, event):
self.cleanup()
return QWidget.closeEvent(self, event)
# Application example
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
scan_control = ScanControl()
# BECclient global variables
client = BECDispatcher().client
client.start()
app = QApplication([])
scan_control = ScanControl(client=client) # allowed_scans=["line_scan", "grid_scan"])
qdarktheme.setup_theme("auto")
window = scan_control
window.show()
app.exec()

View File

@@ -1,223 +0,0 @@
from typing import Literal
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
QGridLayout,
QGroupBox,
QLabel,
QLineEdit,
QSpinBox,
)
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.device_inputs import DeviceLineEdit
class ScanArgType:
DEVICE = "device"
FLOAT = "float"
INT = "int"
BOOL = "bool"
STR = "str"
DEVICEBASE = "DeviceBase"
LITERALS = "dict"
class ScanSpinBox(QSpinBox):
def __init__(
self, parent=None, arg_name: str = None, default: int | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
self.setRange(-9999, 9999)
if default is not None:
self.setValue(default)
class ScanDoubleSpinBox(QDoubleSpinBox):
def __init__(
self, parent=None, arg_name: str = None, default: float | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
self.setRange(-9999, 9999)
if default is not None:
self.setValue(default)
class ScanLineEdit(QLineEdit):
def __init__(
self, parent=None, arg_name: str = None, default: str | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
if default is not None:
self.setText(default)
class ScanCheckBox(QCheckBox):
def __init__(
self, parent=None, arg_name: str = None, default: bool | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
if default is not None:
self.setChecked(default)
class ScanGroupBox(QGroupBox):
WIDGET_HANDLER = {
ScanArgType.DEVICE: DeviceLineEdit,
ScanArgType.DEVICEBASE: DeviceLineEdit,
ScanArgType.FLOAT: ScanDoubleSpinBox,
ScanArgType.INT: ScanSpinBox,
ScanArgType.BOOL: ScanCheckBox,
ScanArgType.STR: ScanLineEdit,
ScanArgType.LITERALS: QComboBox, # TODO figure out combobox logic
}
def __init__(
self,
parent=None,
box_type=Literal["args", "kwargs"],
config: dict | None = None,
*args,
**kwargs,
):
super().__init__(parent=parent, *args, **kwargs)
self.config = config
self.box_type = box_type
self.layout = QGridLayout(self)
self.labels = []
self.widgets = []
self.init_box(self.config)
def init_box(self, config: dict):
box_name = config.get("name", "ScanGroupBox")
self.inputs = config.get("inputs", {})
self.setTitle(box_name)
# Labels
self.add_input_labels(self.inputs, 0)
# Widgets
if self.box_type == "args":
min_bundle = self.config.get("min", 1)
for i in range(1, min_bundle + 1):
self.add_input_widgets(self.inputs, i)
else:
self.add_input_widgets(self.inputs, 1)
def add_input_labels(self, group_inputs: dict, row: int) -> None:
"""
Adds the given arg_group from arg_bundle to the scan control layout. The input labels are always added to the first row.
Args:
group(dict): Dictionary containing the arg_group information.
"""
for column_index, item in enumerate(group_inputs):
arg_name = item.get("name", None)
display_name = item.get("display_name", arg_name)
label = QLabel(text=display_name)
self.layout.addWidget(label, row, column_index)
self.labels.append(label)
def add_input_widgets(self, group_inputs: dict, row) -> None:
"""
Adds the given arg_group from arg_bundle to the scan control layout.
Args:
group_inputs(dict): Dictionary containing the arg_group information.
row(int): The row to add the widgets to.
"""
for column_index, item in enumerate(group_inputs):
arg_name = item.get("name", None)
default = item.get("default", None)
widget = self.WIDGET_HANDLER.get(item["type"], None)
if widget is None:
print(f"Unsupported annotation '{item['type']}' for parameter '{item['name']}'")
continue
if default == "_empty":
default = None
widget_to_add = widget(arg_name=arg_name, default=default)
tooltip = item.get("tooltip", None)
if tooltip is not None:
widget_to_add.setToolTip(item["tooltip"])
self.layout.addWidget(widget_to_add, row, column_index)
self.widgets.append(widget_to_add)
def add_widget_bundle(self):
"""
Adds a new row of widgets to the scan control layout. Only usable for arg_groups.
"""
if self.box_type != "args":
return
arg_max = self.config.get("max", None)
row = self.layout.rowCount()
if arg_max is not None and row >= arg_max:
return
self.add_input_widgets(self.inputs, row)
def remove_widget_bundle(self):
"""
Removes the last row of widgets from the scan control layout. Only usable for arg_groups.
"""
if self.box_type != "args":
return
arg_min = self.config.get("min", None)
row = self.count_arg_rows()
if arg_min is not None and row <= arg_min:
return
for widget in self.widgets[-len(self.inputs) :]:
widget.deleteLater()
self.widgets = self.widgets[: -len(self.inputs)]
def get_parameters(self):
"""
Returns the parameters from the widgets in the scan control layout formated to run scan from BEC.
"""
if self.box_type == "args":
return self._get_arg_parameterts()
elif self.box_type == "kwargs":
return self._get_kwarg_parameters()
def _get_arg_parameterts(self):
args = []
for i in range(1, self.layout.rowCount()):
for j in range(self.layout.columnCount()):
widget = self.layout.itemAtPosition(i, j).widget()
if isinstance(widget, DeviceLineEdit):
value = widget.get_device()
else:
value = WidgetIO.get_value(widget)
args.append(value)
return args
def _get_kwarg_parameters(self):
kwargs = {}
for i in range(self.layout.columnCount()):
widget = self.layout.itemAtPosition(1, i).widget()
if isinstance(widget, DeviceLineEdit):
value = widget.get_device()
else:
value = WidgetIO.get_value(widget)
kwargs[widget.arg_name] = value
return kwargs
def count_arg_rows(self):
widget_rows = 0
for row in range(self.layout.rowCount()):
for col in range(self.layout.columnCount()):
item = self.layout.itemAtPosition(row, col)
if item is not None:
widget = item.widget()
if widget is not None:
if isinstance(widget, DeviceLineEdit):
widget_rows += 1
return widget_rows

View File

@@ -0,0 +1 @@
from .spiral_progress_bar import SpiralProgressBar

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Literal, Optional
from bec_lib.endpoints import EndpointInfo, MessageEndpoints
from bec_lib.endpoints import EndpointInfo
from pydantic import BaseModel, Field, field_validator
from pydantic_core import PydanticCustomError
from qtpy import QtGui
@@ -10,25 +10,24 @@ from qtpy import QtGui
from bec_widgets.utils import BECConnector, ConnectionConfig
class ProgressbarConnections(BaseModel):
class RingConnections(BaseModel):
slot: Literal["on_scan_progress", "on_device_readback"] = None
endpoint: EndpointInfo | str = None
model_config: dict = {"validate_assignment": True}
@field_validator("endpoint")
@classmethod
def validate_endpoint(cls, v, values):
slot = values.data["slot"]
v = v.endpoint if isinstance(v, EndpointInfo) else v
if slot == "on_scan_progress":
if v != MessageEndpoints.scan_progress().endpoint:
if v != "scans/scan_progress":
raise PydanticCustomError(
"unsupported endpoint",
"For slot 'on_scan_progress', endpoint must be MessageEndpoint.scan_progress or 'scans/scan_progress'.",
{"wrong_value": v},
)
elif slot == "on_device_readback":
if not v.startswith(MessageEndpoints.device_readback("").endpoint):
if not v.startswith("internal/devices/readback/"):
raise PydanticCustomError(
"unsupported endpoint",
"For slot 'on_device_readback', endpoint must be MessageEndpoint.device_readback(device) or 'internal/devices/readback/{device}'.",
@@ -37,7 +36,7 @@ class ProgressbarConnections(BaseModel):
return v
class ProgressbarConfig(ConnectionConfig):
class RingConfig(ConnectionConfig):
value: int | float | None = Field(0, description="Value for the progress bars.")
direction: int | None = Field(
-1, description="Direction of the progress bars. -1 for clockwise, 1 for counter-clockwise."
@@ -63,25 +62,16 @@ class ProgressbarConfig(ConnectionConfig):
update_behaviour: Literal["manual", "auto"] | None = Field(
"auto", description="Update behaviour for the progress bars."
)
connections: ProgressbarConnections | None = Field(
default_factory=ProgressbarConnections, description="Connections for the progress bars."
)
class RingConfig(ProgressbarConfig):
index: int | None = Field(0, description="Index of the progress bar. 0 is outer ring.")
start_position: int | None = Field(
90,
description="Start position for the progress bars in degrees. Default is 90 degrees - corespons to "
"the top of the ring.",
connections: RingConnections | None = Field(
default_factory=RingConnections, description="Connections for the progress bars."
)
class Ring(BECConnector):
USER_ACCESS = [
"_get_all_rpc",
"_rpc_id",
"_config_dict",
"get_all_rpc",
"rpc_id",
"config_dict",
"set_value",
"set_color",
"set_background",
@@ -135,7 +125,6 @@ class Ring(BECConnector):
float(max(self.config.min_value, min(self.config.max_value, value))),
self.config.precision,
)
self.parent_progress_widget.update()
def set_color(self, color: str | tuple):
"""
@@ -146,7 +135,6 @@ class Ring(BECConnector):
"""
self.config.color = color
self.color = self.convert_color(color)
self.parent_progress_widget.update()
def set_background(self, color: str | tuple):
"""
@@ -157,7 +145,6 @@ class Ring(BECConnector):
"""
self.config.background_color = color
self.color = self.convert_color(color)
self.parent_progress_widget.update()
def set_line_width(self, width: int):
"""
@@ -167,7 +154,6 @@ class Ring(BECConnector):
width(int): Line width for the ring widget
"""
self.config.line_width = width
self.parent_progress_widget.update()
def set_min_max_values(self, min_value: int | float, max_value: int | float):
"""
@@ -179,7 +165,6 @@ class Ring(BECConnector):
"""
self.config.min_value = min_value
self.config.max_value = max_value
self.parent_progress_widget.update()
def set_start_angle(self, start_angle: int):
"""
@@ -190,7 +175,6 @@ class Ring(BECConnector):
"""
self.config.start_position = start_angle
self.start_position = start_angle * 16
self.parent_progress_widget.update()
@staticmethod
def convert_color(color):
@@ -246,7 +230,7 @@ class Ring(BECConnector):
self.bec_dispatcher.disconnect_slot(
self.config.connections.slot, self.config.connections.endpoint
)
self.config.connections = ProgressbarConnections(slot=slot, endpoint=endpoint)
self.config.connections = RingConnections(slot=slot, endpoint=endpoint)
self.bec_dispatcher.connect_slot(getattr(self, slot), endpoint)
def reset_connection(self):
@@ -256,7 +240,7 @@ class Ring(BECConnector):
self.bec_dispatcher.disconnect_slot(
self.config.connections.slot, self.config.connections.endpoint
)
self.config.connections = ProgressbarConnections()
self.config.connections = RingConnections()
def on_scan_progress(self, msg, meta):
"""

View File

@@ -11,10 +11,10 @@ from qtpy.QtCore import QSize, Slot
from qtpy.QtWidgets import QSizePolicy, QWidget
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig, EntryValidator
from bec_widgets.widgets.ring_progress_bar.ring import Ring, RingConfig
from bec_widgets.widgets.spiral_progress_bar.ring import Ring, RingConfig
class RingProgressBarConfig(ConnectionConfig):
class SpiralProgressBarConfig(ConnectionConfig):
color_map: Optional[str] = Field(
"magma", description="Color scheme for the progress bars.", validate_default=True
)
@@ -32,7 +32,6 @@ class RingProgressBarConfig(ConnectionConfig):
rings: list[RingConfig] | None = Field([], description="List of ring configurations.")
@field_validator("num_bars")
@classmethod
def validate_num_bars(cls, v, values):
min_number_of_bars = values.data.get("min_number_of_bars", None)
max_number_of_bars = values.data.get("max_number_of_bars", None)
@@ -44,7 +43,6 @@ class RingProgressBarConfig(ConnectionConfig):
return v
@field_validator("rings")
@classmethod
def validate_rings(cls, v, values):
if v is not None and v is not []:
num_bars = values.data.get("num_bars", None)
@@ -66,11 +64,11 @@ class RingProgressBarConfig(ConnectionConfig):
_validate_colormap = field_validator("color_map")(Colors.validate_color_map)
class RingProgressBar(BECConnector, QWidget):
class SpiralProgressBar(BECConnector, QWidget):
USER_ACCESS = [
"_get_all_rpc",
"_rpc_id",
"_config_dict",
"get_all_rpc",
"rpc_id",
"config_dict",
"rings",
"update_config",
"add_ring",
@@ -91,20 +89,20 @@ class RingProgressBar(BECConnector, QWidget):
def __init__(
self,
parent=None,
config: RingProgressBarConfig | dict | None = None,
config: SpiralProgressBarConfig | dict | None = None,
client=None,
gui_id: str | None = None,
num_bars: int | None = None,
):
if config is None:
config = RingProgressBarConfig(widget_class=self.__class__.__name__)
config = SpiralProgressBarConfig(widget_class=self.__class__.__name__)
self.config = config
else:
if isinstance(config, dict):
config = RingProgressBarConfig(**config, widget_class=self.__class__.__name__)
config = SpiralProgressBarConfig(**config, widget_class=self.__class__.__name__)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
QWidget.__init__(self, parent=None)
self.get_bec_shortcuts()
self.entry_validator = EntryValidator(self.dev)
@@ -131,7 +129,7 @@ class RingProgressBar(BECConnector, QWidget):
def rings(self, value):
self._rings = value
def update_config(self, config: RingProgressBarConfig | dict):
def update_config(self, config: SpiralProgressBarConfig | dict):
"""
Update the configuration of the widget.
@@ -139,7 +137,7 @@ class RingProgressBar(BECConnector, QWidget):
config(SpiralProgressBarConfig|dict): Configuration to update.
"""
if isinstance(config, dict):
config = RingProgressBarConfig(**config, widget_class=self.__class__.__name__)
config = SpiralProgressBarConfig(**config, widget_class=self.__class__.__name__)
self.config = config
self.clear_all()

View File

@@ -31,7 +31,7 @@ class TextBox(BECConnector, QTextEdit):
USER_ACCESS = ["set_color", "set_text", "set_font_size"]
def __init__(self, parent=None, text: str = "", client=None, config=None, gui_id=None):
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:

View File

@@ -1,70 +1,123 @@
from abc import ABC, abstractmethod
from collections import defaultdict
# pylint: disable=no-name-in-module
from qtpy.QtCore import QSize
from qtpy.QtCore import QSize, QTimer
from qtpy.QtGui import QAction
from qtpy.QtWidgets import QHBoxLayout, QLabel, QSpinBox, QToolBar, QWidget
from qtpy.QtWidgets import QApplication, QStyle, QToolBar, QWidget
class ToolBarAction(ABC):
"""Abstract base class for action creators for the toolbar."""
@abstractmethod
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""Adds an action or widget to a toolbar.
def create(self, target: QWidget):
"""Creates and returns an action to be added to a toolbar.
This method must be implemented by subclasses.
Args:
toolbar (QToolBar): The toolbar to add the action or widget to.
target (QWidget): The target widget for the action.
"""
class ColumnAdjustAction(ToolBarAction):
"""Toolbar spinbox to adjust number of columns in the plot layout"""
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""Creates a access history button for the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the action to.
target (QWidget): The widget that the 'Access Scan History' action will be targeted.
target (QWidget): The widget that the action will target.
Returns:
QAction: The 'Access Scan History' action created for the toolbar.
QAction: The action created for the toolbar.
"""
widget = QWidget()
layout = QHBoxLayout(widget)
label = QLabel("Columns:")
spin_box = QSpinBox()
spin_box.setMinimum(1) # Set minimum value
spin_box.setMaximum(10) # Set maximum value
spin_box.setValue(target.get_column_count()) # Initial value
spin_box.valueChanged.connect(lambda value: target.set_column_count(value))
layout.addWidget(label)
layout.addWidget(spin_box)
toolbar.addWidget(widget)
class OpenFileAction: # (ToolBarAction):
"""Action creator for the 'Open File' action in the toolbar."""
def create(self, target: QWidget):
"""Creates an 'Open File' action for the toolbar.
Args:
target (QWidget): The widget that the 'Open File' action will be targeted.
Returns:
QAction: The 'Open File' action created for the toolbar.
"""
icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_DialogOpenButton)
action = QAction(icon, "Open File", target)
# action = QAction("Open File", target)
action.triggered.connect(target.open_file)
return action
class SaveFileAction:
"""Action creator for the 'Save File' action in the toolbar."""
def create(self, target):
"""Creates a 'Save File' action for the toolbar.
Args:
target (QWidget): The widget that the 'Save File' action will be targeted.
Returns:
QAction: The 'Save File' action created for the toolbar.
"""
icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton)
action = QAction(icon, "Save File", target)
# action = QAction("Save File", target)
action.triggered.connect(target.save_file)
return action
class RunScriptAction:
"""Action creator for the 'Run Script' action in the toolbar."""
def create(self, target):
"""Creates a 'Run Script' action for the toolbar.
Args:
target (QWidget): The widget that the 'Run Script' action will be targeted.
Returns:
QAction: The 'Run Script' action created for the toolbar.
"""
icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)
action = QAction(icon, "Run Script", target)
# action = QAction("Run Script", target)
action.triggered.connect(target.run_script)
return action
class ModularToolBar(QToolBar):
"""Modular toolbar with optional automatic initialization.
Args:
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
actions (list[ToolBarAction], optional): A list of action creators to populate the toolbar. Defaults to None.
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
color (str, optional): The background color of the toolbar. Defaults to "black".
auto_init (bool, optional): If True, automatically populates the toolbar based on the parent widget.
"""
def __init__(self, parent=None, actions=None, target_widget=None, color: str = "black"):
def __init__(self, parent=None, auto_init=True):
super().__init__(parent)
self.auto_init = auto_init
self.handler = {
"BECEditor": [OpenFileAction(), SaveFileAction(), RunScriptAction()],
# BECMonitor: [SomeOtherAction(), AnotherAction()], # Example for another widget
}
self.setStyleSheet("QToolBar { background: transparent; }")
# Set the icon size for the toolbar
self.setIconSize(QSize(20, 20))
self.widgets = defaultdict(dict)
self.set_background_color(color)
if self.auto_init:
QTimer.singleShot(0, self.auto_detect_and_populate)
if actions is not None and target_widget is not None:
self.populate_toolbar(actions, target_widget)
def auto_detect_and_populate(self):
"""Automatically detects the parent widget and populates the toolbar with relevant actions."""
if not self.auto_init:
return
def populate_toolbar(self, actions: dict, target_widget):
parent_widget = self.parent()
if parent_widget is None:
return
parent_widget_class_name = type(parent_widget).__name__
for widget_type_name, actions in self.handler.items():
if parent_widget_class_name == widget_type_name:
self.populate_toolbar(actions, parent_widget)
return
def populate_toolbar(self, actions, target_widget):
"""Populates the toolbar with a set of actions.
Args:
@@ -72,13 +125,20 @@ class ModularToolBar(QToolBar):
target_widget (QWidget): The widget that the actions will target.
"""
self.clear()
for action_id, action in actions.items():
action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action
for action_creator in actions:
action = action_creator.create(target_widget)
self.addAction(action)
def set_background_color(self, color: str):
self.setStyleSheet(f"QToolBar {{ background: {color}; }}")
self.setIconSize(QSize(20, 20))
self.setMovable(False)
self.setFloatable(False)
self.setContentsMargins(0, 0, 0, 0)
def set_manual_actions(self, actions, target_widget):
"""Manually sets the actions for the toolbar.
Args:
actions (list[QAction or ToolBarAction]): A list of actions or action creators to populate the toolbar.
target_widget (QWidget): The widget that the actions will target.
"""
self.clear()
for action in actions:
if isinstance(action, QAction):
self.addAction(action)
elif isinstance(action, ToolBarAction):
self.addAction(action.create(target_widget))

View File

@@ -1,93 +0,0 @@
import os
import select
import shlex
import signal
import subprocess
import sys
from bec_widgets.widgets.website.website import WebsiteWidget
class VSCodeEditor(WebsiteWidget):
"""
A widget to display the VSCode editor.
"""
token = "bec"
host = "127.0.0.1"
port = 7000
USER_ACCESS = []
def __init__(self, parent=None, config=None, client=None, gui_id=None):
self.process = None
self._url = f"http://{self.host}:{self.port}?tkn={self.token}"
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id)
self.start_server()
def start_server(self):
"""
Start the server.
This method starts the server for the VSCode editor in a subprocess.
"""
cmd = shlex.split(
f"code serve-web --port {self.port} --connection-token={self.token} --accept-server-license-terms"
)
self.process = subprocess.Popen(
cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, preexec_fn=os.setsid
)
os.set_blocking(self.process.stdout.fileno(), False)
while self.process.poll() is None:
readylist, _, _ = select.select([self.process.stdout], [], [], 1)
if self.process.stdout in readylist:
output = self.process.stdout.read(1024)
if output and f"available at {self._url}" in output:
break
self.set_url(self._url)
def closeEvent(self, event):
"""
Hook for the close event to terminate the server.
"""
self.cleanup_vscode()
super().closeEvent(event)
def cleanup_vscode(self):
"""
Cleanup the VSCode editor.
"""
if not self.process or self.process.poll() is not None:
return
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
self.process.wait()
def cleanup(self):
"""
Cleanup the widget. This method is called from the dock area when the widget is removed.
"""
self.cleanup_vscode()
return super().cleanup()
def close(self):
"""
Close the widget.
"""
self.cleanup_vscode()
return super().close()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = VSCodeEditor()
widget.show()
app.exec_()
widget.bec_dispatcher.disconnect_all()
widget.client.shutdown()

View File

@@ -1,19 +1,10 @@
from qtpy.QtCore import QUrl, qInstallMessageHandler
from qtpy.QtCore import QUrl
from qtpy.QtWebEngineWidgets import QWebEngineView
from qtpy.QtWidgets import QApplication
from bec_widgets.utils import BECConnector
def suppress_qt_messages(type_, context, msg):
if context.category in ["js", "default"]:
return
print(msg)
qInstallMessageHandler(suppress_qt_messages)
class WebsiteWidget(BECConnector, QWebEngineView):
"""
A simple widget to display a website
@@ -21,7 +12,7 @@ class WebsiteWidget(BECConnector, QWebEngineView):
USER_ACCESS = ["set_url", "get_url", "reload", "back", "forward"]
def __init__(self, parent=None, url: str = None, config=None, client=None, gui_id=None):
def __init__(self, url: str = None, parent=None, config=None, client=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
QWebEngineView.__init__(self, parent=parent)
self.set_url(url)

View File

@@ -21,7 +21,7 @@ api_reference/api_reference.md
:gutter: 5
```{grid-item-card}
:link: developer.getting_started
:link: user.getting_started
:link-type: ref
:img-top: /assets/rocket_launch_48dp.svg
:text-align: center
@@ -32,7 +32,7 @@ Learn how to install BEC Widgets and get started with the framework.
```
```{grid-item-card}
:link: developer.widgets
:link: user.widgets
:link-type: ref
:img-top: /assets/apps_48dp.svg
:text-align: center

View File

@@ -0,0 +1,353 @@
(developer.widgets.how_to_develop_a_widget)=
# How to Develop a Widget
This section provides a step-by-step guide on how to develop a new widget for BEC Widgets. We will develop a simple widget that allows you to press a button and specify a user-defined action. The general widget will be based on a [QPushButton](https://doc.qt.io/qt-6/qpushbutton.html) which we will extend to be capable of communicating with BEC through the interface provided by BEC Widgets.
## Button to start a scan
Developing a new widget in BEC Widgets is straightforward. Let's create a widget that allows a user to press a button and execute a `line_scan` in BEC. The proper location to create a new widget is either in the `bec_widgets/widgets` directory, or the beamline plugin widget direction, i.e. `csaxs_bec/bec_widgets`, depending on where your development takes place.
### Step 1: Create a new widget class
We first create a simple class that inherits from the `QPushButton` class.
The following code snippet demonstrates how to create a new widget:
``` python
from qtpy.QtWidgets import QPushButton
class StartScanButton(QPushButton):
def __init__(self, parent=None):
QPushButton.__init__(self, parent=parent)
# Connect the button to the on_click method
self.clicked.connect(self.on_click)
def on_click(self):
pass
```
So far we have created the button, but we have not yet put any logic to the `on_click` event of the button.
Adding the functionality to be able to execute a scans will be tackled in the next step.
````{note}
To make the button work as a standalone application, you can simply add the following lines at the end.
``` python
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = StartScanButton()
widget.show()
sys.exit(app.exec_())
```
````
### Step 2: Connect with BEC, implement *on_click* functionality
To be able to start a scan, we need to communicate with BEC. This can be facilitated easily by inheriting additionally from [`BECConnector`](../../api_reference/_autosummary/bec_widgets.utils.bec_connector.BECConnector).
With the *BECConnector*, we will also have to pass the *client* ([BECClient](https://bec.readthedocs.io/en/latest/api_reference/_autosummary/bec_lib.client.BECClient.html)) and the *gui_id* (str) to init function of both, our *StartScanButton* widget and the `super().__init__(client=client, gui_id=gui_id)` call.
In the init of *BECConnector*, the client will be initialised and stored in `self.client`, which gives us access to the available scan objects via `self.client.scans`.
``` python
from qtpy.QtWidgets import QPushButton
from bec_widgets.utils import BECConnector
class StartScanButton(BECConnector, QPushButton):
def __init__(self, parent=None, client:=None, gui_id=None):
super().__init__(client=client, gui_id=gui_id)
QPushButton.__init__(self, parent=parent)
# Set a default scan command, args and kwargs
self.scan_name = "line_scan"
self.scan_args = (dev.samx, -5, 5)
self.scan_kwargs = {"steps": 50, "exp_time": 0.1, "relative": True}
# Set the text of the button to display the current scan name
self.set_button_text()
# Connect the button to the on_click method
self.clicked.connect(self.on_click)
def set_button_text(self):
"""Set the text of the button"""
self.setText(f"Start {self.scan_name}")
def run_command(self):
"""Run the scan command."""
# Get the scan command from the scans library
scan_command = getattr(self.client.scans, self.scan_name)
# Run the scan command
scan_report = scan_command(*self.scan_args, **self.scan_kwargs)
# Wait for the scan to finish
scan_report.wait()
def on_click(self):
"""Start a line scan"""
self.run_command()
```
```{note}
For the args and kwargs of the scan command, we are using the same syntax as in the client: `dev.samx` is not a string but the same object as in the client.
```
In the *run_command* method, we retrieve the scan object from the client by its name, and execute the method with all *args* and *kwargs* that we have set.
The current implementation of *run_command* is a blocking call due to `scan_report.wait()`, which is not ideal for a GUI application since it freezes the GUI. We will adress this in the next step.
### Step 3: Improving the widget interactivity
To not freeze the GUI, we need to run the scan command in a separate thread. We can either use [QThreads](https://doc.qt.io/qtforpython-6/PySide6/QtCore/QThread.html) or the Python [threading module](https://docs.python.org/3/library/threading.html#thread-objects). In this example, we will use the Python threading module. In addition, we add a method `update_style` to change the style of the button to indicate to the user that the scan is running. We also extend the cleanup procedure of `BECConnector` to ensure that the thread is stopped when the widget is closed. This is good practice to avoid having threads running in the background when the widget is closed.
``` python
def update_style(self, mode: Literal["ready", "running"]):
"""Update the style of the button based on the mode.
Args:
mode (Literal["ready", "running"): The mode of the button.
"""
if mode == "ready":
self.setStyleSheet(
"background-color: #4CAF50; color: white; font-size: 16px; padding: 10px 24px;"
)
elif mode == "running":
self.setStyleSheet(
"background-color: #808080; color: white; font-size: 16px; padding: 10px 24px;"
)
def run_command(self):
"""Run the scan command."""
# Switch the style of the button
self.update_style("running")
# Disable the buttom while the scan is running
self.setEnabled(False)
# Get the scan command from the scans library
scan_command = getattr(self.scans, self.scan_name)
# Run the scan command
scan_report = scan_command(*self.scan_args, **self.scan_kwargs)
# Wait for the scan to finish
scan_report.wait()
# Reactivate the button
self.setEnabled(True)
# Switch the style of the button back to ready
self.update_style("ready")
def on_click(self):
"""Start a line scan"""
thread = threading.Thread(target=self.run_command)
thread.start()
def cleanup(self):
"""Cleanup the widget"""
# stop thread
# stop the thread or if this is implemented via QThread, ensure stopping of QThread.
# Ideally, the BECConnector should take care of this automatically.
# Important to call super().cleanup() to ensure that the cleanup of the BECConnector is also called
super().cleanup()
```
We now added started the scan in a separate thread, which allows the GUI to remain responsive. We also added a method to change the style of the button to indicate to the user that the scan is running. The cleanup method ensures that the thread is stopped when the widget is closed. In a last step, we know like to make the scan command configurable.
### Step 4: Make the scan command configurable
In order to make the scan comman configurable, we implement a method `set_scan_command` which allows the user to set the scan command, arguments and keyword arguments.
This method should also become available through the RPC interface of BEC Widgets, so we add the class attribute `USER_ACCESS` which is a list of strings with functions that should become available for the CLI.
``` python
def set_scan_command(
self, scan_name: str, args: tuple, kwargs: dict
):
"""Set the scan command to run.
Args:
scan_name (str): The name of the scan command.
args (tuple): The arguments for the scan command.
kwargs (dict): The keyword arguments for the scan command.
"""
# check if scan_command starts with scans.
if not getattr(self.client.scans, scan_name):
raise ValueError(
f"The scan type must be implemented in the scan library of BEC, received {scan_name}"
)
self.scan_name = scan_name
self.scan_args = args
self.scan_kwargs = kwargs
self.set_button_text()
```
### Step 5: Generate client interface for RPC
We have now prepared the widget which is fully functional as a standalone widget. But we also want to make it available to the BEC command-line-interface (CLI), for which we prepared the **USER_ACCESS** class attribute.
The communication between the BEC IPythonClient and the widget is done vie the RPC interface of BEC Widgets.
For this, we need to run the `bec_widgets.cli.generate_cli` script to generate the CLI interface.
``` bash
python bec_widgets.cli.generate_cli --core
# alternatively use the entry point from BEC Widgets
bw-generate-cli
```
This will generate a new client with all relevant methods in [`bec_widgets.cli.client.py`](../../api_reference/_autosummary/bec_widgets.bec_widgets.cli.client.rst).
The last step is to make the RPCWidgetHandler class aware of the widget, which means to add the name of the widget to the widgets list in the [`RPCWidgetHandler`](../../api_reference/_autosummary/bec_widgets.bec_widgets.cli.rpc_widget_handler.RPCWidgetHandler.rst) class.
````{dropdown} View code: RPCWidgetHandler class
:icon: code-square
:animate: fade-in-slide-down
```{literalinclude} ../../../bec_widgets/cli/rpc_widget_handler.py
:language: python
:pyobject: RPCWidgetHandler
```
````
With this, we have a fully functional widget that allows the user to start a scan with a button. The scan command, arguments and keyword arguments can be set by the user.
The full code is shown once again below:
````{dropdown} View code: Full code of the StartScanButton widget
:icon: code-square
:animate: fade-in-slide-down
```
import threading
from typing import Literal
from qtpy.QtWidgets import QPushButton
from bec_widgets.utils import BECConnector
class StartScanButton(BECConnector, QPushButton):
"""A button to start a line scan.
Args:
parent: The parent widget.
client (BECClient): The BEC client.
gui_id (str): The unique ID of the widget.
"""
USER_ACCESS = ["set_scan_command"]
def __init__(self, parent=None, client=None, gui_id=None):
super().__init__(client=client, gui_id=gui_id)
QPushButton.__init__(self, parent=parent)
# Set the scan command to None
self.scan_command = None
# Set default scan command
self.scan_name = "line_scan"
self.scan_args = (dev.samx, -5, 5)
self.scan_kwargs = {"steps": 50, "exp_time": 0.1, "relative": True}
# Set the text of the button
self.set_button_text()
# Set the style of the button
self.update_style("ready")
# Connect the button to the on_click method
self.clicked.connect(self.on_click)
def update_style(self, mode: Literal["ready", "running"]):
"""Update the style of the button based on the mode.
Args:
mode (Literal["ready", "running"): The mode of the button.
"""
if mode == "ready":
self.setStyleSheet(
"background-color: #4CAF50; color: white; font-size: 16px; padding: 10px 24px;"
)
elif mode == "running":
self.setStyleSheet(
"background-color: #808080; color: white; font-size: 16px; padding: 10px 24px;"
)
def set_button_text(self):
"""Set the text of the button."""
self.setText(f"Start {self.scan_name}")
def set_scan_command(self, scan_name: str, args: tuple, kwargs: dict):
"""Set the scan command to run.
Args:
scan_name (str): The name of the scan command.
args (tuple): The arguments for the scan command.
kwargs (dict): The keyword arguments for the scan command.
"""
# check if scan_command starts with scans.
if not getattr(self.client.scans, scan_name):
raise ValueError(
f"The scan type must be implemented in the scan library of BEC, received {scan_name}"
)
self.scan_name = scan_name
self.scan_args = args
self.scan_kwargs = kwargs
self.set_button_text()
def run_command(self):
"""Run the scan command."""
# Switch the style of the button
self.update_style("running")
# Disable the buttom while the scan is running
self.setEnabled(False)
# Get the scan command from the scans library
scan_command = getattr(self.scans, self.scan_name)
# Run the scan command
scan_report = scan_command(*self.scan_args, **self.scan_kwargs)
# Wait for the scan to finish
scan_report.wait()
# Reactivate the button
self.setEnabled(True)
# Switch the style of the button back to ready
self.update_style("ready")
def on_click(self):
"""Start a line scan"""
thread = threading.Thread(target=self.run_command)
thread.start()
def cleanup(self):
"""Cleanup the widget"""
# stop thread
# stop the thread or if this is implemented via QThread, ensure stopping of QThread.
# Ideally, the BECConnector should take care of this automatically.
# Important to call super().cleanup() to ensure that the cleanup of the BECConnector is also called
super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = StartScanButton()
widget.show()
sys.exit(app.exec_())
```
````
### Step 6: Write a test for the widget
We highly recommend writing tests for the widget to ensure that they work as expected. This allows to run the tests automatically in a CI/CD pipeline and to ensure that the widget works as expected not only now but als in the future.
The following code snippet shows an example to test the set_scan_command from the `StartScanButton` widget.
``` python
import pytest
from bec_widgets.widgets.start_scan_button import StartScanButton
from .client_mocks import mocked_client
@pytest.fixture
def test_scan_button(qtbot, mocked_client):
widget = StartScanButton(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
def test_set_scan_command(test_scan_button):
"""Test the set_scan_command function."""
test_scan_button.set_scan_command(
scan_name="grid_scan",
args=(dev.samx, -5, 5, 10, dev.samy, -5, 5, 20),
kwargs={"exp_time": 0.1, "relative": True},
)
# Check first if all parameter have been properly set
assert test_scan_button.scan_name == "grid_scan"
assert test_scan_button.scan_args == (dev.samx, -5, 5, 10, dev.samy, -5, 5, 20)
assert test_scan_button.scan_kwargs == {"exp_time": 0.1, "relative": True}
# Next, we check if the displayed text of the button has been updated
# We use the .text() method from the QPushButton class to retrieve the text displayed
assert test_scan_button.text() == "Start grid_scan"
```

View File

@@ -8,4 +8,5 @@ maxdepth: 2
hidden: false
---
how_to_develop_a_widget/
```

View File

@@ -7,6 +7,5 @@ sphinx-copybutton
myst-parser
sphinx-design
PyQt6
PyQt6-WebEngine
bec-widgets
tomli

View File

@@ -48,7 +48,7 @@ users to interact. BEC Widgets must be placed in the window:
```
from qtpy.QWidgets import QMainWindow
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets import BECFigure
window = QMainWindow()
bec_figure = BECFigure(gui_id="my_gui_app_id")
@@ -78,7 +78,7 @@ Final example:
```
import sys
from qtpy.QtWidgets import QMainWindow, QApplication
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets import BECFigure
from bec_widgets.utils.bec_dispatcher import BECDispatcher
# creation of the Qt application

View File

@@ -13,13 +13,13 @@ To install BEC Widgets using the pip package manager, execute the following comm
```bash
pip install 'bec_widgets[pyqt6]'
pip install bec_widgets[pyqt6]
```
In case you want to use PyQt5, you can install it by using the following command:
```bash
pip install 'bec_widgets[pyqt5]'
pip install bec_widgets[pyqt5]
```
**Troubleshooting**

View File

@@ -97,11 +97,11 @@ Note, we chain commands here which is possible since the `add_dock` and `add_wid
cam_widget.set_title("Camera Image Eiger")
cam_widget.set_vrange(vmin=0, vmax=100)
```
As a final step, we can now add also a RingProgressBar to a new dock, and perform a grid_scan with the motors *samx* and *samy*.
As a final step, we can now add also a SpiralProgressBar to a new dock, and perform a grid_scan with the motors *samx* and *samy*.
As you see in the example below, all docks are arranged below each other. This is the default behavior of the `add_dock` method. However, the docks can be freely arranged by drag and drop as desired by the user. We invite you to explore this by yourself following the example in the video, and build your custom GUI with BEC Widgets.
```python
prog_bar = gui.add_dock(name="prog_dock").add_widget('RingProgressBar')
prog_bar = gui.add_dock(name="prog_dock").add_widget('SpiralProgressBar')
prog_bar.set_line_widths(15)
scans.grid_scan(dev.samy, -2, 2, 10, dev.samx, -5, 5, 10, exp_time=0.1, relative=False)
```

View File

@@ -7,6 +7,7 @@ In the following, we describe 4 different type of widgets thaat are available in
![BECFigure.png](BECFigure.png)
(user.widgets.waveform_1d)=
## [1D Waveform Widget](/api_reference/_autosummary/bec_widgets.cli.client.BECWaveform)
**Purpose:** This widget provides a straightforward visualization of 1D data. It is particularly useful for plotting positioner movements against detector readings, enabling users to observe correlations and patterns in a simple, linear format.
@@ -19,12 +20,11 @@ In the following, we describe 4 different type of widgets thaat are available in
**Example of Use:**
![Waveform 1D](./w1D.gif)
**Code example 1 - adding curves**
**Code example**
The following code snipped demonstrates how to create a 1D waveform plot using BEC Widgets within BEC. More details about BEC Widgets in BEC can be found in the getting started section within the [introduction to the command line.](user.command_line_introduction)
```python
# adds a new dock, a new BECFigure and a BECWaveForm to the dock
plt = gui.add_dock().add_widget('BECFigure').plot(x_name='samx', y_name='bpm4i')
plt = gui.add_dock().add_widget('BECFigure').plot('samx', 'bpm4i')
# add a second curve to the same plot
plt.plot(x_name='samx', y_name='bpm3i')
plt.set_title("Gauss plots vs. samx")
@@ -39,48 +39,6 @@ dev.bpm4i.sim.select_sim_model("GaussianModel")
dev.bpm3i.sim.select_sim_model("StepModel")
```
**Code example 2 - Adding Data Processing Pipeline Curve with LMFit Models**
Together with the scan curve, one can also add a second curve that fits the signal using a specified model
from [LMFit](https://lmfit.github.io/lmfit-py/builtin_models.html). The following code snippet demonstrates how to
create a 1D waveform curve with an attached DAP process, or how to add a DAP process to an existing curve using the BEC
CLI. Please note that for this example, both devices were set as Gaussian signals.
```python
# Add a new dock, a new BECFigure, and a BECWaveForm to the dock with a GaussianModel DAP
plt = gui.add_dock().add_widget('BECFigure').plot(x_name='samx', y_name='bpm4i', dap="GaussianModel")
# Add a second curve to the same plot without DAP
plt.plot(x_name='samx', y_name='bpm3a')
# Add DAP to the second curve
plt.add_dap(x_name='samx', y_name='bpm3a', dap="GaussianModel")
```
To get the parameters of the fit, one has to retrieve the curve objects and call the dap_params property.
```python
# Get the curve object by name from the legend
dap_bpm4i = plt.get_curve("bpm4i-bpm4i-GaussianModel")
dap_bpm3a = plt.get_curve("bpm3a-bpm3a-GaussianModel")
# Get the parameters of the fit
print(dap_bpm4i.dap_params)
# Output
{'amplitude': 197.399639720862,
'center': 5.013486095404885,
'sigma': 0.9820868875739888}
print(dap_bpm3a.dap_params)
# Output
{'amplitude': 698.3072786185278,
'center': 0.9702840866173836,
'sigma': 1.97139754785518}
```
![Waveform 1D_DAP](./bec_figure_dap.gif)
(user.widgets.scatter_2d)=
## [2D Scatter Plot](/api_reference/_autosummary/bec_widgets.cli.client.BECWaveform)

Some files were not shown because too many files have changed in this diff Show More