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

Compare commits

...

52 Commits

Author SHA1 Message Date
4947549a20 test: update client and server tests to include token and ACL data parameters 2025-03-10 16:56:49 +01:00
59e4c2f612 WIP - feat(acl): implement ACL handling and token encryption in BECWidgetsCLIServer and client utilities 2025-03-07 22:11:34 +01:00
semantic-release
15e11b287d 1.25.0
Automatically generated by python-semantic-release
2025-03-07 15:19:37 +00:00
7cbebbb1f0 feat(waveform): add slice handling and reset functionality for async updates 2025-03-07 15:44:46 +01:00
semantic-release
66f4f9bfa8 1.24.5
Automatically generated by python-semantic-release
2025-03-06 14:51:03 +00:00
66c6c7fa50 fix: add support for additional keyword arguments in widget constructors 2025-03-06 15:39:16 +01:00
semantic-release
31c3337300 1.24.4
Automatically generated by python-semantic-release
2025-03-05 19:59:54 +00:00
2c506ee3c8 fix(cli/server): handle RedisError during heartbeat emission to properly close the app even if the Redis connection is lost 2025-03-05 20:41:33 +01:00
semantic-release
25423f4a3a 1.24.3
Automatically generated by python-semantic-release
2025-03-05 09:46:53 +00:00
fa91366dcb fix(multi_waveform): update on_async_readback to use structured metadata for async updates with "add" instead of "extend" 2025-03-04 22:31:14 +01:00
semantic-release
4db0f9f10c 1.24.2
Automatically generated by python-semantic-release
2025-02-27 10:08:57 +00:00
46b1a228be fix(e2e): added wait time to flaky e2e 2025-02-27 10:54:36 +01:00
semantic-release
531018b0ac 1.24.1
Automatically generated by python-semantic-release
2025-02-26 21:06:09 +00:00
8679b5f08b test: extended test coverage for axis settings, plot base and qt toolbar action 2025-02-26 21:54:33 +01:00
6f2c2401ac refactor(plot_base): toolbar buttons adapted for the Switch actions from toolbar; plot export and mouse modes consolidated into one switch button 2025-02-26 21:54:33 +01:00
6d1106e33e fix(toolbar): Switch Actions for default checked actions fixed 2025-02-26 21:54:33 +01:00
90a184643a refactor(axis_settings): spinbox migrated to new BECSpinBoxes 2025-02-26 21:54:33 +01:00
3aa2f2225f fix(plot_base): ability to choose between popup or side panel gui mode 2025-02-26 21:54:33 +01:00
semantic-release
f54e69f1cf 1.24.0
Automatically generated by python-semantic-release
2025-02-26 11:20:07 +00:00
7309c1dede feat: add metadata widget to scan control 2025-02-26 12:08:32 +01:00
1c0021f98b fix: make scan metadata use collapsible frame 2025-02-26 12:08:32 +01:00
d32952a0d5 style: isort 2025-02-26 12:08:32 +01:00
5206528fec feat: add expandable/collapsible frame 2025-02-26 12:08:32 +01:00
42665b69c5 fix: replace add'l md table w/ tree view 2025-02-26 12:08:32 +01:00
semantic-release
209c898e3d 1.23.1
Automatically generated by python-semantic-release
2025-02-24 13:54:40 +00:00
6a43554f3b fix: update redis mock for changes in bec 2025-02-24 14:43:02 +01:00
semantic-release
95c931af0b 1.23.0
Automatically generated by python-semantic-release
2025-02-24 10:00:25 +00:00
f19d9485df feat(bec_spin_box): double spin box with setting inside for defining decimals 2025-02-24 10:49:10 +01:00
semantic-release
575c988c4f 1.22.0
Automatically generated by python-semantic-release
2025-02-19 16:54:57 +00:00
6b08f7cfb2 refactor(toolbar): added dark mode button for testing appearance for the toolbar example 2025-02-19 17:43:49 +01:00
6ae33a23a6 test(toolbar): blocking tests fixed 2025-02-19 17:08:56 +01:00
facb8c30ff fix(toolbar): update_separators logic updated, there cannot be two separators next to each other 2025-02-19 15:44:44 +01:00
333570ba2f feat(toolbar): SwitchableToolBarButton 2025-02-19 15:42:31 +01:00
ef36a7124d fix(toolbar): widget actions are more compact 2025-02-19 15:02:17 +01:00
c2c022154b fix(toolbar): QMenu Icons are visible 2025-02-19 15:02:17 +01:00
4c4f1592c2 fix(modular_toolbar): add action to an already existing bundle 2025-02-19 15:02:17 +01:00
semantic-release
d7fb291877 1.21.4
Automatically generated by python-semantic-release
2025-02-19 13:29:43 +00:00
ae18279685 fix(colors): pyqtgraph styling updated on the app level 2025-02-19 14:18:18 +01:00
97c0ed53df fix(plot_base): mouse interactions default state fetch to toolbar 2025-02-19 14:18:18 +01:00
ff8e282034 refactor(plot_base): Change the PlotWidget to GraphicalLayoutWidget 2025-02-19 14:18:18 +01:00
semantic-release
440f36f289 1.21.3
Automatically generated by python-semantic-release
2025-02-19 12:44:37 +00:00
0addef5f17 fix(bec_signal_proxy): unblock signal timer cleanup added 2025-02-19 13:33:16 +01:00
semantic-release
8c2a5e61fc 1.21.2
Automatically generated by python-semantic-release
2025-02-18 14:41:43 +00:00
056731c9ad fix(client_utils): autoupdate has correct propagation of BECDockArea to plugin repos 2025-02-18 15:06:53 +01:00
semantic-release
911c81a167 1.21.1
Automatically generated by python-semantic-release
2025-02-17 14:54:21 +00:00
8651314d93 build:unlock pyside version 2025-02-17 15:18:29 +01:00
383936ffc2 fix(bec_connector): workers stored in reference to not be cleaned up with garbage collector 2025-02-17 15:18:29 +01:00
semantic-release
4378d33880 1.21.0
Automatically generated by python-semantic-release
2025-02-17 10:37:33 +00:00
1708bd405f feat: generated form for scan metadata 2025-02-17 11:21:08 +01:00
12811eccdb tests(scan_control): fixed hard-coded redis paths 2025-02-13 17:49:00 +01:00
semantic-release
5959fa87de 1.20.0
Automatically generated by python-semantic-release
2025-02-06 15:37:33 +00:00
b3217b7ca5 feat(widget): add LogPanel widget
hopefully without segfaults - compared to first implementation:
- explicitly set parent of all dialog components
- try/except and log for redis new message callback
- pass in ServiceStatusMixin and explicitly clean it up
2025-02-06 16:26:02 +01:00
88 changed files with 4019 additions and 648 deletions

View File

@@ -1,6 +1,205 @@
# CHANGELOG
## v1.25.0 (2025-03-07)
### Features
- **waveform**: Add slice handling and reset functionality for async updates
([`7cbebbb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7cbebbb1f00ea2e2b3678c96b183a877e59c5240))
## v1.24.5 (2025-03-06)
### Bug Fixes
- Add support for additional keyword arguments in widget constructors
([`66c6c7f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/66c6c7fa5075dcd5b6729fa3c2166aa821a6c51d))
## v1.24.4 (2025-03-05)
### Bug Fixes
- **cli/server**: Handle RedisError during heartbeat emission to properly close the app even if the
Redis connection is lost
([`2c506ee`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2c506ee3c8bcf924c651fddffe4f3f9a2ffd19a4))
## v1.24.3 (2025-03-05)
### Bug Fixes
- **multi_waveform**: Update on_async_readback to use structured metadata for async updates with
"add" instead of "extend"
([`fa91366`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fa91366dcbb383319dc0a0f26400aa93ee445299))
## v1.24.2 (2025-02-27)
### Bug Fixes
- **e2e**: Added wait time to flaky e2e
([`46b1a22`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/46b1a228be4ef5eb21ecf6c7020a2cd05d06b61a))
## v1.24.1 (2025-02-26)
### Bug Fixes
- **plot_base**: Ability to choose between popup or side panel gui mode
([`3aa2f22`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3aa2f2225fba499b648d191ea27553b6db303c18))
- **toolbar**: Switch Actions for default checked actions fixed
([`6d1106e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6d1106e33e1fc3839244b11a601fb71e81a65e61))
### Refactoring
- **axis_settings**: Spinbox migrated to new BECSpinBoxes
([`90a1846`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/90a184643aaaaabaa4feb02d2f406fe2bb9daecc))
- **plot_base**: Toolbar buttons adapted for the Switch actions from toolbar; plot export and mouse
modes consolidated into one switch button
([`6f2c240`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6f2c2401ac99b2b8a9af9af76854669a248b516b))
### Testing
- Extended test coverage for axis settings, plot base and qt toolbar action
([`8679b5f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8679b5f08bef8a4a2b6338d9bee4cd70d564f288))
## v1.24.0 (2025-02-26)
### Bug Fixes
- Make scan metadata use collapsible frame
([`1c0021f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1c0021f98b8e0419dba883b891a6035653c0ba0d))
- Replace add'l md table w/ tree view
([`42665b6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/42665b69c5cca60a9e5f2d7bd43dbfe5da5a7eb3))
### Code Style
- Isort
([`d32952a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d32952a0d590b03007271427bd85f00b88ef0851))
### Features
- Add expandable/collapsible frame
([`5206528`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5206528feccaf192f3d5872ac785470562b493f9))
- Add metadata widget to scan control
([`7309c1d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7309c1dede2ec93bf08f84f13596ce18dfdb1476))
## v1.23.1 (2025-02-24)
### Bug Fixes
- Update redis mock for changes in bec
([`6a43554`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6a43554f3b57045325f57bdd5079d7f91af40bb6))
## v1.23.0 (2025-02-24)
### Features
- **bec_spin_box**: Double spin box with setting inside for defining decimals
([`f19d948`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f19d9485df403cb755315ac1a0ff4402d7a85f77))
## v1.22.0 (2025-02-19)
### Bug Fixes
- **modular_toolbar**: Add action to an already existing bundle
([`4c4f159`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4c4f1592c29974bb095c3c8325e93a1383efa289))
- **toolbar**: Qmenu Icons are visible
([`c2c0221`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c2c022154bddc15d81eb55aad912d8fe1e34c698))
- **toolbar**: Update_separators logic updated, there cannot be two separators next to each other
([`facb8c3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/facb8c30ffa3b12a97c7c68f8594b0354372ca17))
- **toolbar**: Widget actions are more compact
([`ef36a71`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ef36a7124d54319c2cd592433c95e4f7513e982e))
### Features
- **toolbar**: Switchabletoolbarbutton
([`333570b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/333570ba2fe67cb51fdbab17718003dfdb7f7b55))
### Refactoring
- **toolbar**: Added dark mode button for testing appearance for the toolbar example
([`6b08f7c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b08f7cfb2115609a6dc6f681631ecfae23fa899))
### Testing
- **toolbar**: Blocking tests fixed
([`6ae33a2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6ae33a23a62eafb7c820e1fde9d6d91ec1796e55))
## v1.21.4 (2025-02-19)
### Bug Fixes
- **colors**: Pyqtgraph styling updated on the app level
([`ae18279`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ae182796855719437bdf911c2e969e3f438d6982))
- **plot_base**: Mouse interactions default state fetch to toolbar
([`97c0ed5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/97c0ed53df21053fef9811c3dea3b79020137030))
### Refactoring
- **plot_base**: Change the PlotWidget to GraphicalLayoutWidget
([`ff8e282`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ff8e282034f0970b69cf0447fc5f88b4f30bf470))
## v1.21.3 (2025-02-19)
### Bug Fixes
- **bec_signal_proxy**: Unblock signal timer cleanup added
([`0addef5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0addef5f172a7cc1412ac146a6eec2a2caa8ad9c))
## v1.21.2 (2025-02-18)
### Bug Fixes
- **client_utils**: Autoupdate has correct propagation of BECDockArea to plugin repos
([`056731c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/056731c9add7d92f7da7fa833343cf65e8f383a8))
## v1.21.1 (2025-02-17)
### Bug Fixes
- **bec_connector**: Workers stored in reference to not be cleaned up with garbage collector
([`383936f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/383936ffc2bd7d2e088d3367c76b14efa3d1732c))
## v1.21.0 (2025-02-17)
### Features
- Generated form for scan metadata
([`1708bd4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1708bd405f86b1353828b01fbf5f98383a19ec2a))
## v1.20.0 (2025-02-06)
### Features
- **widget**: Add LogPanel widget
([`b3217b7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b3217b7ca5cabe8798f06787de4ae3f3ec1af3b6))
hopefully without segfaults - compared to first implementation: - explicitly set parent of all
dialog components - try/except and log for redis new message callback - pass in ServiceStatusMixin
and explicitly clean it up
## v1.19.2 (2025-02-06)
### Bug Fixes

View File

@@ -31,6 +31,7 @@ class Widgets(str, enum.Enum):
DeviceComboBox = "DeviceComboBox"
DeviceLineEdit = "DeviceLineEdit"
LMFitDialog = "LMFitDialog"
LogPanel = "LogPanel"
Minesweeper = "Minesweeper"
PositionIndicator = "PositionIndicator"
PositionerBox = "PositionerBox"
@@ -3183,6 +3184,26 @@ class LMFitDialog(RPCBase):
"""
class LogPanel(RPCBase):
@rpc_call
def set_plain_text(self, text: str) -> None:
"""
Set the plain text of the widget.
Args:
text (str): The text to set.
"""
@rpc_call
def set_html_text(self, text: str) -> None:
"""
Set the HTML text of the widget.
Args:
text (str): The text to set.
"""
class Minesweeper(RPCBase): ...

View File

@@ -11,9 +11,11 @@ from contextlib import contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING
import msgpack
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 cryptography.fernet import Fernet
import bec_widgets.cli.client as client
from bec_widgets.cli.auto_updates import AutoUpdates
@@ -67,7 +69,14 @@ def _get_output(process, logger) -> None:
logger.error(f"Error reading process output: {str(e)}")
def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger=None) -> None:
def _start_plot_process(
gui_id: str,
gui_class: type,
config: dict | str,
acl_data: bytes | None = None,
token: bytes | None = None,
logger=None,
) -> None:
"""
Start the plot in a new process.
@@ -84,6 +93,8 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
env_dict = os.environ.copy()
env_dict["PYTHONUNBUFFERED"] = "1"
env_dict["BEC_GUI_ACL"] = acl_data or b""
env_dict["BEC_GUI_TOKEN"] = token or b""
if logger is None:
stdout_redirect = subprocess.DEVNULL
@@ -179,6 +190,7 @@ class BECGuiClient(RPCBase):
self._gui_started_event = threading.Event()
self._process = None
self._process_output_processing_thread = None
self._fernet = None
@property
def windows(self):
@@ -199,7 +211,7 @@ class BECGuiClient(RPCBase):
# if the module is not found, we skip it
if spec is None:
continue
return ep.load()(gui=self)
return ep.load()(gui=self._top_level["main"].widget)
except Exception as e:
logger.error(f"Error loading auto update script from plugin: {str(e)}")
return None
@@ -263,6 +275,18 @@ class BECGuiClient(RPCBase):
self._do_show_all()
self._gui_started_event.set()
# def _update_gui_acls(self, username: str, password: str | None):
# self._client.connector.send(
# MessageEndpoints.gui_acls(self._gui_id),
# messages.CredentialsMessage(
# credentials={
# "token": self._fernet.encrypt(
# msgpack.dumps({"username": username, "password": password})
# )
# }
# ),
# )
def start_server(self, wait=False) -> None:
"""
Start the GUI server, and execute callback when it is launched
@@ -271,8 +295,18 @@ class BECGuiClient(RPCBase):
logger.success("GUI starting...")
self._startup_timeout = 5
self._gui_started_event.clear()
encr_token = Fernet.generate_key()
self._fernet = Fernet(encr_token)
conn = self._client.connector._redis_conn.connection_pool.connection_kwargs
acl_data = {"username": conn.get("username"), "password": conn.get("password")}
acl_data = self._fernet.encrypt(msgpack.dumps(acl_data))
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id, self.__class__, self._client._service_config.config, logger=logger
self._gui_id,
self.__class__,
self._client._service_config.config,
acl_data=acl_data,
token=encr_token,
logger=logger,
)
def gui_started_callback(callback):

View File

@@ -2,17 +2,21 @@ from __future__ import annotations
import functools
import json
import os
import signal
import sys
import types
from contextlib import contextmanager, redirect_stderr, redirect_stdout
from typing import Union
import msgpack
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 cryptography.fernet import Fernet
from qtpy.QtCore import Qt, QTimer
from redis.exceptions import RedisError
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
@@ -56,7 +60,10 @@ class BECWidgetsCLIServer:
client=None,
config=None,
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
token: str = None,
) -> None:
self._fernet = Fernet(token) if token else None
self._init_acls(config)
self.status = messages.BECStatus.BUSY
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
@@ -66,6 +73,8 @@ class BECWidgetsCLIServer:
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self.gui)
# self.dispatcher.connect_slot(self.on_acl_update, MessageEndpoints.gui_acl(self.gui_id))
self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
)
@@ -78,6 +87,13 @@ class BECWidgetsCLIServer:
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
def _init_acls(self, config: ServiceConfig):
acl_data = os.getenv("BEC_GUI_ACL")
if not acl_data:
return
acl_data = msgpack.loads(self._fernet.decrypt(acl_data))
config.config["acl"] = acl_data
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
@@ -95,6 +111,9 @@ class BECWidgetsCLIServer:
logger.debug(f"RPC instruction executed successfully: {res}")
self.send_response(request_id, True, {"result": res})
def on_acl_update(self, msg: dict, metadata: dict):
logger.debug(f"Received ACL update: {msg}, metadata: {metadata}")
def send_response(self, request_id: str, accepted: bool, msg: dict):
self.client.connector.set_and_publish(
MessageEndpoints.gui_instruction_response(request_id),
@@ -142,11 +161,14 @@ class BECWidgetsCLIServer:
def emit_heartbeat(self):
logger.trace(f"Emitting heartbeat for {self.gui_id}")
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
expire=10,
)
try:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
expire=10,
)
except RedisError as exc:
logger.error(f"Error while emitting heartbeat: {exc}")
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
logger.info(f"Shutting down server with gui_id: {self.gui_id}")
@@ -186,13 +208,10 @@ def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config:
# if no config is provided, use the default config
service_config = ServiceConfig()
# bec_logger.configure(
# service_config.redis,
# QtRedisConnector,
# service_name="BECWidgetsCLIServer",
# service_config=service_config.service_config,
# )
server = BECWidgetsCLIServer(gui_id=gui_id, config=service_config, gui_class=gui_class)
token = os.getenv("BEC_GUI_TOKEN")
server = BECWidgetsCLIServer(
gui_id=gui_id, config=service_config, gui_class=gui_class, token=token
)
return server

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QLayout,
QSizePolicy,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
class ExpandableGroupFrame(QFrame):
EXPANDED_ICON_NAME: str = "collapse_all"
COLLAPSED_ICON_NAME: str = "expand_all"
def __init__(self, title: str, parent: QWidget | None = None, expanded: bool = True) -> None:
super().__init__(parent=parent)
self._expanded = expanded
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
self._expansion_button = QToolButton()
self._update_icon()
self._title = QLabel(f"<b>{title}</b>")
self._title_layout.addWidget(self._expansion_button)
self._title_layout.addWidget(self._title)
self._contents = QWidget()
self._layout.addWidget(self._contents)
self._expansion_button.clicked.connect(self.switch_expanded_state)
self.expanded = self._expanded # type: ignore
def set_layout(self, layout: QLayout) -> None:
self._contents.setLayout(layout)
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore
@SafeSlot()
def switch_expanded_state(self):
self.expanded = not self.expanded # type: ignore
self._update_icon()
@SafeProperty(bool)
def expanded(self): # type: ignore
return self._expanded
@expanded.setter
def expanded(self, expanded: bool):
self._expanded = expanded
self._contents.setVisible(expanded)
self.updateGeometry()
def _update_icon(self):
self._expansion_button.setIcon(
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
if self.expanded
else material_icon(
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
)
)

View File

@@ -1,6 +1,6 @@
import pyqtgraph as pg
from qtpy.QtCore import Property
from qtpy.QtWidgets import QApplication, QFrame, QVBoxLayout, QWidget
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
@@ -31,22 +31,20 @@ class RoundedFrame(BECWidget, QFrame):
# Apply rounded frame styling
self.setProperty("skip_settings", True)
self.setObjectName("roundedFrame")
self.update_style()
# Create a layout for the frame
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
self.layout = QHBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
# Add the content widget to the layout
if content_widget:
layout.addWidget(content_widget)
self.layout.addWidget(content_widget)
# Store reference to the content widget
self.content_widget = content_widget
# Automatically apply initial styles to the PlotWidget if applicable
if isinstance(content_widget, pg.PlotWidget):
self.apply_plot_widget_style()
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
self.apply_plot_widget_style()
self._connect_to_theme_change()
@@ -65,10 +63,6 @@ class RoundedFrame(BECWidget, QFrame):
self.update_style()
# Update PlotWidget's background color and axis styles if applicable
if isinstance(self.content_widget, pg.PlotWidget):
self.apply_plot_widget_style()
@Property(int)
def radius(self):
"""Radius of the rounded corners."""
@@ -92,6 +86,7 @@ class RoundedFrame(BECWidget, QFrame):
}}
"""
)
self.apply_plot_widget_style()
def apply_plot_widget_style(self, border: str = "none"):
"""
@@ -100,35 +95,16 @@ class RoundedFrame(BECWidget, QFrame):
Args:
border (str): Border style (e.g., 'none', '1px solid red').
"""
if isinstance(self.content_widget, pg.PlotWidget):
# Sync PlotWidget's background color with the RoundedFrame's background color
self.content_widget.setBackground(self.background_color)
# Calculate contrast-optimized axis and label colors
if self.background_color == "#e9ecef": # Light mode
label_color = "#000000"
axis_color = "#666666"
else: # Dark mode
label_color = "#FFFFFF"
axis_color = "#CCCCCC"
# Apply axis label and tick colors
plot_item = self.content_widget.getPlotItem()
for axis in ["left", "right", "top", "bottom"]:
plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color))
plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color))
# Change title color
plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color)
if isinstance(self.content_widget, pg.GraphicsLayoutWidget):
# Apply border style via stylesheet
self.content_widget.setStyleSheet(
f"""
PlotWidget {{
GraphicsLayoutWidget {{
border: {border}; /* Explicitly set the border */
}}
"""
)
self.content_widget.setBackground(self.background_color)
class ExampleApp(QWidget): # pragma: no cover
@@ -142,26 +118,27 @@ class ExampleApp(QWidget): # pragma: no cover
dark_button = DarkModeButton()
# Create PlotWidgets
plot1 = pg.PlotWidget()
plot1.plot([1, 3, 2, 4, 6, 5], pen="r")
plot1 = pg.GraphicsLayoutWidget()
plot_item_1 = pg.PlotItem()
plot_item_1.plot([1, 3, 2, 4, 6, 5], pen="r")
plot1.plot_item = plot_item_1
plot2 = pg.PlotWidget()
plot2.plot([1, 2, 4, 8, 16, 32], pen="r")
plot2 = pg.GraphicsLayoutWidget()
plot_item_2 = pg.PlotItem()
plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r")
plot2.plot_item = plot_item_2
# Wrap PlotWidgets in RoundedFrame
rounded_plot1 = RoundedFrame(content_widget=plot1, theme_update=True)
rounded_plot2 = RoundedFrame(content_widget=plot2, theme_update=True)
round = RoundedFrame()
# Add to layout
layout.addWidget(dark_button)
layout.addWidget(rounded_plot1)
layout.addWidget(rounded_plot2)
layout.addWidget(round)
self.setLayout(layout)
# Simulate theme change after 2 seconds
from qtpy.QtCore import QTimer
def change_theme():

View File

@@ -1,6 +1,6 @@
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.qt_utils.error_popups import SafeSlot
class SettingWidget(QWidget):
@@ -20,14 +20,14 @@ class SettingWidget(QWidget):
def set_target_widget(self, target_widget: QWidget):
self.target_widget = target_widget
@Slot()
@SafeSlot()
def accept_changes(self):
"""
Accepts the changes made in the settings widget and applies them to the target widget.
"""
pass
@Slot(dict)
@SafeSlot(dict)
def display_current_settings(self, config_dict: dict):
"""
Displays the current settings of the target widget in the settings widget.
@@ -54,12 +54,13 @@ class SettingsDialog(QDialog):
settings_widget: SettingWidget = None,
window_title: str = "Settings",
config: dict = None,
modal: bool = False,
*args,
**kwargs,
):
super().__init__(parent, *args, **kwargs)
self.setModal(False)
self.setModal(modal)
self.setWindowTitle(window_title)
@@ -92,7 +93,7 @@ class SettingsDialog(QDialog):
ok_button.setDefault(True)
ok_button.setAutoDefault(True)
@Slot()
@SafeSlot()
def accept(self):
"""
Accept the changes made in the settings widget and close the dialog.
@@ -100,7 +101,7 @@ class SettingsDialog(QDialog):
self.widget.accept_changes()
super().accept()
@Slot()
@SafeSlot()
def apply_changes(self):
"""
Apply the changes made in the settings widget without closing the dialog.

View File

@@ -8,7 +8,7 @@ from collections import defaultdict
from typing import Dict, List, Literal, Tuple
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt
from qtpy.QtCore import QSize, Qt, QTimer
from qtpy.QtGui import QAction, QColor, QIcon
from qtpy.QtWidgets import (
QApplication,
@@ -18,15 +18,54 @@ from qtpy.QtWidgets import (
QMainWindow,
QMenu,
QSizePolicy,
QStyle,
QToolBar,
QToolButton,
QVBoxLayout,
QWidget,
)
import bec_widgets
from bec_widgets.utils.colors import set_theme
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
# Ensure that icons are shown in menus (especially on macOS)
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
class LongPressToolButton(QToolButton):
def __init__(self, *args, long_press_threshold=500, **kwargs):
super().__init__(*args, **kwargs)
self.long_press_threshold = long_press_threshold
self._long_press_timer = QTimer(self)
self._long_press_timer.setSingleShot(True)
self._long_press_timer.timeout.connect(self.handleLongPress)
self._pressed = False
self._longPressed = False
def mousePressEvent(self, event):
self._pressed = True
self._longPressed = False
self._long_press_timer.start(self.long_press_threshold)
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
self._pressed = False
if self._longPressed:
self._longPressed = False
self._long_press_timer.stop()
event.accept() # Prevent normal click action after a long press
return
self._long_press_timer.stop()
super().mouseReleaseEvent(event)
def handleLongPress(self):
if self._pressed:
self._longPressed = True
self.showMenu()
class ToolBarAction(ABC):
"""
@@ -84,6 +123,21 @@ class IconAction(ToolBarAction):
toolbar.addAction(self.action)
class QtIconAction(ToolBarAction):
def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.standard_icon = standard_icon
self.icon = QApplication.style().standardIcon(standard_icon)
self.action = QAction(self.icon, self.tooltip, parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar, target):
toolbar.addAction(self.action)
def get_icon(self):
return self.icon
class MaterialIconAction(ToolBarAction):
"""
Action with a Material icon for the toolbar.
@@ -111,7 +165,7 @@ class MaterialIconAction(ToolBarAction):
self.icon_name = icon_name
self.filled = filled
self.color = color
# Generate the icon
# Generate the icon using the material_icon helper
self.icon = material_icon(
self.icon_name,
size=(20, 20),
@@ -119,7 +173,6 @@ class MaterialIconAction(ToolBarAction):
filled=self.filled,
color=self.color,
)
# Immediately create an QAction with the given parent
self.action = QAction(self.icon, self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
@@ -152,7 +205,7 @@ class DeviceSelectionAction(ToolBarAction):
device_combobox (DeviceComboBox): The combobox for selecting the device.
"""
def __init__(self, label: str, device_combobox):
def __init__(self, label: str | None = None, device_combobox=None):
super().__init__()
self.label = label
self.device_combobox = device_combobox
@@ -161,15 +214,101 @@ class DeviceSelectionAction(ToolBarAction):
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)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
if self.label is not None:
label = QLabel(f"{self.label}")
layout.addWidget(label)
if self.device_combobox is not None:
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 SwitchableToolBarAction(ToolBarAction):
"""
A split toolbar action that combines a main action and a drop-down menu for additional actions.
The main button displays the currently selected action's icon and tooltip. Clicking on the main button
triggers that action. Clicking on the drop-down arrow displays a menu with alternative actions. When an
alternative action is selected, it becomes the new default and its callback is immediately executed.
This design mimics the behavior seen in Adobe Photoshop or Affinity Designer toolbars.
Args:
actions (dict): A dictionary mapping a unique key to a ToolBarAction instance.
initial_action (str, optional): The key of the initial default action. If not provided, the first action is used.
tooltip (str, optional): An optional tooltip for the split action; if provided, it overrides the default action's tooltip.
checkable (bool, optional): Whether the action is checkable. Defaults to True.
parent (QWidget, optional): Parent widget for the underlying QAction.
"""
def __init__(
self,
actions: Dict[str, ToolBarAction],
initial_action: str = None,
tooltip: str = None,
checkable: bool = True,
default_state_checked: bool = False,
parent=None,
):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.actions = actions
self.current_key = initial_action if initial_action is not None else next(iter(actions))
self.parent = parent
self.checkable = checkable
self.default_state_checked = default_state_checked
self.main_button = None
self.menu_actions: Dict[str, QAction] = {}
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the split action to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the action to.
target (QWidget): The target widget for the action.
"""
self.main_button = LongPressToolButton(toolbar)
self.main_button.setPopupMode(QToolButton.MenuButtonPopup)
self.main_button.setCheckable(self.checkable)
default_action = self.actions[self.current_key]
self.main_button.setIcon(default_action.get_icon())
self.main_button.setToolTip(default_action.tooltip)
self.main_button.clicked.connect(self._trigger_current_action)
menu = QMenu(self.main_button)
self.menu_actions = {}
for key, action_obj in self.actions.items():
menu_action = QAction(action_obj.get_icon(), action_obj.tooltip, self.main_button)
menu_action.setIconVisibleInMenu(True)
menu_action.setCheckable(self.checkable)
menu_action.setChecked(key == self.current_key)
menu_action.triggered.connect(lambda checked, k=key: self.set_default_action(k))
menu.addAction(menu_action)
self.menu_actions[key] = menu_action
self.main_button.setMenu(menu)
toolbar.addWidget(self.main_button)
def _trigger_current_action(self):
action_obj = self.actions[self.current_key]
action_obj.action.trigger()
def set_default_action(self, key: str):
self.current_key = key
new_action = self.actions[self.current_key]
self.main_button.setIcon(new_action.get_icon())
self.main_button.setToolTip(new_action.tooltip)
# Update check state of menu items
for k, menu_act in self.menu_actions.items():
menu_act.setChecked(k == key)
new_action.action.trigger()
def get_icon(self) -> QIcon:
return self.actions[self.current_key].get_icon()
class WidgetAction(ToolBarAction):
"""
Action for adding any widget to the toolbar.
@@ -180,15 +319,23 @@ class WidgetAction(ToolBarAction):
"""
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None):
super().__init__(parent)
super().__init__(icon_path=None, tooltip=label, checkable=False)
self.label = label
self.widget = widget
self.container = None
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
container = QWidget()
layout = QHBoxLayout(container)
"""
Adds the widget to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the widget to.
target (QWidget): The target widget for the action.
"""
self.container = QWidget()
layout = QHBoxLayout(self.container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
layout.setSpacing(0)
if self.label is not None:
label_widget = QLabel(f"{self.label}")
@@ -209,19 +356,12 @@ class WidgetAction(ToolBarAction):
layout.addWidget(self.widget)
toolbar.addWidget(container)
toolbar.addWidget(self.container)
# Store the container as the action to allow toggling visibility.
self.action = self.container
@staticmethod
def calculate_minimum_width(combo_box: QComboBox) -> int:
"""
Calculate the minimum width required to display the longest item in the combo box.
Args:
combo_box (QComboBox): The combo box to calculate the width for.
Returns:
int: The calculated minimum width in pixels.
"""
font_metrics = combo_box.fontMetrics()
max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count()))
return max_width + 60
@@ -261,12 +401,15 @@ class ExpandableMenuAction(ToolBarAction):
menu = QMenu(button)
for action_id, action in self.actions.items():
sub_action = QAction(action.tooltip, target)
if hasattr(action, "icon_path"):
sub_action.setIconVisibleInMenu(True)
if action.icon_path:
icon = QIcon()
icon.addFile(action.icon_path, size=QSize(20, 20))
sub_action.setIcon(icon)
elif hasattr(action, "get_icon"):
sub_action.setIcon(action.get_icon())
elif hasattr(action, "get_icon") and callable(action.get_icon):
sub_icon = action.get_icon()
if sub_icon and not sub_icon.isNull():
sub_action.setIcon(sub_icon)
sub_action.setCheckable(action.checkable)
menu.addAction(sub_action)
self.widgets[action_id] = sub_action
@@ -289,7 +432,6 @@ class ToolbarBundle:
self.bundle_id = bundle_id
self._actions: dict[str, ToolBarAction] = {}
# If you passed in a list of tuples, load them into the dictionary
if actions is not None:
for action_id, action in actions:
self._actions[action_id] = action
@@ -331,7 +473,7 @@ class ModularToolBar(QToolBar):
actions (dict, optional): A dictionary of action creators to populate the toolbar. Defaults to None.
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
orientation (Literal["horizontal", "vertical"], optional): The initial orientation of the toolbar. Defaults to "horizontal".
background_color (str, optional): The background color of the toolbar. Defaults to "rgba(0, 0, 0, 0)" - transparent background.
background_color (str, optional): The background color of the toolbar. Defaults to "rgba(0, 0, 0, 0)".
"""
def __init__(
@@ -378,7 +520,7 @@ class ModularToolBar(QToolBar):
Sets the background color and other appearance settings.
Args:
color(str): The background color of the toolbar.
color (str): The background color of the toolbar.
"""
self.setIconSize(QSize(20, 20))
self.setMovable(False)
@@ -402,100 +544,133 @@ class ModularToolBar(QToolBar):
def update_material_icon_colors(self, new_color: str | tuple | QColor):
"""
Updates the color of all MaterialIconAction icons in the toolbar.
Updates the color of all MaterialIconAction icons.
Args:
new_color (str | tuple | QColor): The new color for the icons.
new_color (str | tuple | QColor): The new color.
"""
for action in self.widgets.values():
if isinstance(action, MaterialIconAction):
action.color = new_color
# Refresh the icon
updated_icon = action.get_icon()
action.action.setIcon(updated_icon)
def add_action(self, action_id: str, action: ToolBarAction, target_widget: QWidget):
"""
Adds a new standalone action to the toolbar dynamically.
Adds a new standalone action dynamically.
Args:
action_id (str): Unique identifier for the action.
action (ToolBarAction): The action to add to the toolbar.
target_widget (QWidget): The target widget for the action.
action_id (str): Unique identifier.
action (ToolBarAction): The action to add.
target_widget (QWidget): The target widget.
"""
if action_id in self.widgets:
raise ValueError(f"Action with ID '{action_id}' already exists.")
action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action
self.toolbar_items.append(("action", action_id))
self.update_separators() # Update separators after adding the action
self.update_separators()
def hide_action(self, action_id: str):
"""
Hides a specific action on the toolbar.
Hides a specific action.
Args:
action_id (str): Unique identifier for the action to hide.
action_id (str): Unique identifier.
"""
if action_id not in self.widgets:
raise ValueError(f"Action with ID '{action_id}' does not exist.")
action = self.widgets[action_id]
if hasattr(action, "action") and isinstance(action.action, QAction):
if hasattr(action, "action") and action.action is not None:
action.action.setVisible(False)
self.update_separators() # Update separators after hiding the action
self.update_separators()
def show_action(self, action_id: str):
"""
Shows a specific action on the toolbar.
Shows a specific action.
Args:
action_id (str): Unique identifier for the action to show.
action_id (str): Unique identifier.
"""
if action_id not in self.widgets:
raise ValueError(f"Action with ID '{action_id}' does not exist.")
action = self.widgets[action_id]
if hasattr(action, "action") and isinstance(action.action, QAction):
if hasattr(action, "action") and action.action is not None:
action.action.setVisible(True)
self.update_separators() # Update separators after showing the action
self.update_separators()
def add_bundle(self, bundle: ToolbarBundle, target_widget: QWidget):
"""
Adds a bundle of actions to the toolbar, separated by a separator.
Adds a bundle of actions, separated by a separator.
Args:
bundle (ToolbarBundle): The bundle to add.
target_widget (QWidget): The target widget for the actions.
bundle (ToolbarBundle): The bundle.
target_widget (QWidget): The target widget.
"""
if bundle.bundle_id in self.bundles:
raise ValueError(f"ToolbarBundle with ID '{bundle.bundle_id}' already exists.")
# Add a separator before the bundle (but not to first one)
if self.toolbar_items:
sep = SeparatorAction()
sep.add_to_toolbar(self, target_widget)
self.toolbar_items.append(("separator", None))
# Add each action in the bundle
for action_id, action_obj in bundle.actions.items():
action_obj.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action_obj
# Register the bundle
self.bundles[bundle.bundle_id] = list(bundle.actions.keys())
self.toolbar_items.append(("bundle", bundle.bundle_id))
self.update_separators()
self.update_separators() # Update separators after adding the bundle
def add_action_to_bundle(self, bundle_id: str, action_id: str, action, target_widget: QWidget):
"""
Dynamically adds an action to an existing bundle.
Args:
bundle_id (str): The bundle ID.
action_id (str): Unique identifier.
action (ToolBarAction): The action to add.
target_widget (QWidget): The target widget.
"""
if bundle_id not in self.bundles:
raise ValueError(f"Bundle '{bundle_id}' does not exist.")
if action_id in self.widgets:
raise ValueError(f"Action with ID '{action_id}' already exists.")
action.add_to_toolbar(self, target_widget)
new_qaction = action.action
self.removeAction(new_qaction)
bundle_action_ids = self.bundles[bundle_id]
if bundle_action_ids:
last_bundle_action = self.widgets[bundle_action_ids[-1]].action
actions_list = self.actions()
try:
index = actions_list.index(last_bundle_action)
except ValueError:
self.addAction(new_qaction)
else:
if index + 1 < len(actions_list):
before_action = actions_list[index + 1]
self.insertAction(before_action, new_qaction)
else:
self.addAction(new_qaction)
else:
self.addAction(new_qaction)
self.widgets[action_id] = action
self.bundles[bundle_id].append(action_id)
self.update_separators()
def contextMenuEvent(self, event):
"""
Overrides the context menu event to show a list of toolbar actions with checkboxes and icons, including separators.
Overrides the context menu event to show toolbar actions with checkboxes and icons.
Args:
event(QContextMenuEvent): The context menu event.
event (QContextMenuEvent): The context menu event.
"""
menu = QMenu(self)
# Iterate through the toolbar items in order
for item_type, identifier in self.toolbar_items:
if item_type == "separator":
menu.addSeparator()
@@ -503,18 +678,16 @@ class ModularToolBar(QToolBar):
self.handle_bundle_context_menu(menu, identifier)
elif item_type == "action":
self.handle_action_context_menu(menu, identifier)
# Connect the triggered signal after all actions are added
menu.triggered.connect(self.handle_menu_triggered)
menu.exec_(event.globalPos())
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
"""
Adds a set of bundle actions to the context menu.
Adds bundle actions to the context menu.
Args:
menu (QMenu): The context menu to which the actions are added.
bundle_id (str): The identifier for the bundle.
menu (QMenu): The context menu.
bundle_id (str): The bundle identifier.
"""
action_ids = self.bundles.get(bundle_id, [])
for act_id in action_ids:
@@ -535,7 +708,6 @@ class ModularToolBar(QToolBar):
# Set the icon if available
if qaction.icon() and not qaction.icon().isNull():
menu_action.setIcon(qaction.icon())
menu.addAction(menu_action)
def handle_action_context_menu(self, menu: QMenu, action_id: str):
@@ -565,73 +737,95 @@ class ModularToolBar(QToolBar):
menu.addAction(menu_action)
def handle_menu_triggered(self, action):
"""Handles the toggling of toolbar actions from the context menu."""
"""
Handles the triggered signal from the context menu.
Args:
action: Action triggered.
"""
action_id = action.data()
if action_id:
self.toggle_action_visibility(action_id, action.isChecked())
def toggle_action_visibility(self, action_id: str, visible: bool):
"""
Toggles the visibility of a specific action on the toolbar.
Toggles the visibility of a specific action.
Args:
action_id(str): Unique identifier for the action to toggle.
visible(bool): Whether the action should be visible.
action_id (str): Unique identifier.
visible (bool): Whether the action should be visible.
"""
if action_id not in self.widgets:
return
tool_action = self.widgets[action_id]
if hasattr(tool_action, "action") and isinstance(tool_action.action, QAction):
if hasattr(tool_action, "action") and tool_action.action is not None:
tool_action.action.setVisible(visible)
self.update_separators()
def update_separators(self):
"""
Hide separators that are adjacent to another separator or have no actions next to them.
Hide separators that are adjacent to another separator or have no non-separator actions between them.
"""
toolbar_actions = self.actions()
# First pass: set visibility based on surrounding non-separator actions.
for i, action in enumerate(toolbar_actions):
if not action.isSeparator():
continue
# Find the previous visible action
prev_visible = None
for j in range(i - 1, -1, -1):
if toolbar_actions[j].isVisible():
prev_visible = toolbar_actions[j]
break
# Find the next visible action
next_visible = None
for j in range(i + 1, len(toolbar_actions)):
if toolbar_actions[j].isVisible():
next_visible = toolbar_actions[j]
break
# Determine if the separator should be hidden
# Hide if both previous and next visible actions are separators or non-existent
if (prev_visible is None or prev_visible.isSeparator()) and (
next_visible is None or next_visible.isSeparator()
):
action.setVisible(False)
else:
action.setVisible(True)
# Second pass: ensure no two visible separators are adjacent.
prev = None
for action in toolbar_actions:
if action.isVisible() and action.isSeparator():
if prev and prev.isSeparator():
action.setVisible(False)
else:
prev = action
else:
if action.isVisible():
prev = action
class MainWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Toolbar / ToolbarBundle Demo")
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
self.test_label = QLabel(text="This is a test label.")
self.central_widget.layout = QVBoxLayout(self.central_widget)
self.central_widget.layout.addWidget(self.test_label)
# Create a modular toolbar
self.toolbar = ModularToolBar(parent=self, target_widget=self)
self.addToolBar(self.toolbar)
# Example: Add a single bundle
self.add_switchable_button_checkable()
self.add_switchable_button_non_checkable()
self.add_widget_actions()
self.add_bundles()
self.add_menus()
# For theme testing
self.dark_button = DarkModeButton(toolbar=True)
dark_mode_action = WidgetAction(label=None, widget=self.dark_button)
self.toolbar.add_action("dark_mode", dark_mode_action, self)
def add_bundles(self):
home_action = MaterialIconAction(
icon_name="home", tooltip="Home", checkable=True, parent=self
)
@@ -651,12 +845,11 @@ class MainWindow(QMainWindow): # pragma: no cover
)
self.toolbar.add_bundle(main_actions_bundle, target_widget=self)
# Another bundle
search_action = MaterialIconAction(
icon_name="search", tooltip="Search", checkable=True, parent=self
icon_name="search", tooltip="Search", checkable=False, parent=self
)
help_action = MaterialIconAction(
icon_name="help", tooltip="Help", checkable=True, parent=self
icon_name="help", tooltip="Help", checkable=False, parent=self
)
second_bundle = ToolbarBundle(
bundle_id="secondary_actions",
@@ -664,9 +857,102 @@ class MainWindow(QMainWindow): # pragma: no cover
)
self.toolbar.add_bundle(second_bundle, target_widget=self)
new_action = MaterialIconAction(
icon_name="counter_1", tooltip="New Action", checkable=True, parent=self
)
self.toolbar.add_action_to_bundle(
"main_actions", "new_action", new_action, target_widget=self
)
def add_menus(self):
menu_material_actions = {
"mat1": MaterialIconAction(
icon_name="home", tooltip="Material Home", checkable=True, parent=self
),
"mat2": MaterialIconAction(
icon_name="settings", tooltip="Material Settings", checkable=True, parent=self
),
"mat3": MaterialIconAction(
icon_name="info", tooltip="Material Info", checkable=True, parent=self
),
}
menu_qt_actions = {
"qt1": QtIconAction(
standard_icon=QStyle.SP_FileIcon, tooltip="Qt File", checkable=True, parent=self
),
"qt2": QtIconAction(
standard_icon=QStyle.SP_DirIcon, tooltip="Qt Directory", checkable=True, parent=self
),
"qt3": QtIconAction(
standard_icon=QStyle.SP_TrashIcon, tooltip="Qt Trash", checkable=True, parent=self
),
}
expandable_menu_material = ExpandableMenuAction(
label="Material Menu", actions=menu_material_actions
)
expandable_menu_qt = ExpandableMenuAction(label="Qt Menu", actions=menu_qt_actions)
self.toolbar.add_action("material_menu", expandable_menu_material, self)
self.toolbar.add_action("qt_menu", expandable_menu_qt, self)
def add_switchable_button_checkable(self):
action1 = MaterialIconAction(
icon_name="counter_1", tooltip="Action 1", checkable=True, parent=self
)
action2 = MaterialIconAction(
icon_name="counter_2", tooltip="Action 2", checkable=True, parent=self
)
switchable_action = SwitchableToolBarAction(
actions={"action1": action1, "action2": action2},
initial_action="action1",
tooltip="Switchable Action",
checkable=True,
parent=self,
)
self.toolbar.add_action("switchable_action", switchable_action, self)
action1.action.toggled.connect(
lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}")
)
action2.action.toggled.connect(
lambda checked: self.test_label.setText(f"Action 2 triggered, checked = {checked}")
)
def add_switchable_button_non_checkable(self):
action1 = MaterialIconAction(
icon_name="counter_1", tooltip="Action 1", checkable=False, parent=self
)
action2 = MaterialIconAction(
icon_name="counter_2", tooltip="Action 2", checkable=False, parent=self
)
switchable_action = SwitchableToolBarAction(
actions={"action1": action1, "action2": action2},
initial_action="action1",
tooltip="Switchable Action",
checkable=True,
parent=self,
)
self.toolbar.add_action("switchable_action_no_toggle", switchable_action, self)
action1.action.triggered.connect(
lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}")
)
action2.action.triggered.connect(
lambda checked: self.test_label.setText(f"Action 2 triggered, checked = {checked}")
)
switchable_action.actions["action1"].action.setChecked(True)
def add_widget_actions(self):
combo = QComboBox()
combo.addItems(["Option 1", "Option 2", "Option 3"])
self.toolbar.add_action("device_combo", WidgetAction(label="Device:", widget=combo), self)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
set_theme("light")
main_window = MainWindow()
main_window.show()
sys.exit(app.exec_())

View File

@@ -111,7 +111,7 @@ class BECConnector:
# register widget to rpc register
# be careful: when registering, and the object is not a BECWidget,
# cleanup has to called manually since there is no 'closeEvent'
# cleanup has to be called manually since there is no 'closeEvent'
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
@@ -119,6 +119,8 @@ class BECConnector:
self.error_utility = ErrorPopupUtility()
self._thread_pool = QThreadPool.globalInstance()
# Store references to running workers so they're not garbage collected prematurely.
self._workers = []
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
"""
@@ -147,11 +149,14 @@ class BECConnector:
>>> 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)
# Keep a reference to the worker so it is not garbage collected.
self._workers.append(worker)
# When the worker is done, remove it from our list.
worker.signals.completed.connect(lambda: self._workers.remove(worker))
self._thread_pool.start(worker)
return worker
@@ -183,10 +188,10 @@ class BECConnector:
@_config_dict.setter
def _config_dict(self, config: BaseModel) -> None:
"""
Get the configuration of the widget.
Set the configuration of the widget.
Returns:
dict: The configuration of the widget.
Args:
config (BaseModel): The new configuration model.
"""
self.config = config
@@ -195,8 +200,8 @@ class BECConnector:
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.
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:
@@ -212,8 +217,8 @@ class BECConnector:
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.
path (str | None): 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)
@@ -232,8 +237,8 @@ class BECConnector:
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.
path (str | None): 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)
@@ -241,7 +246,6 @@ class BECConnector:
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)
@@ -250,7 +254,7 @@ class BECConnector:
Set the GUI ID for the widget.
Args:
gui_id(str): GUI ID
gui_id (str): GUI ID.
"""
self.config.gui_id = gui_id
self.gui_id = gui_id
@@ -271,7 +275,7 @@ class BECConnector:
"""Update the client and device manager from BEC and create object for BEC shortcuts.
Args:
client: BEC client
client: BEC client.
"""
self.client = client
self.get_bec_shortcuts()
@@ -282,12 +286,10 @@ class BECConnector:
Update the configuration for the widget.
Args:
config(ConnectionConfig): Configuration settings.
config (ConnectionConfig | dict): Configuration settings.
"""
if isinstance(config, dict):
config = ConnectionConfig(**config)
# TODO add error handler
self.config = config
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
@@ -295,12 +297,45 @@ class BECConnector:
Get the configuration of the widget.
Args:
dict_output(bool): If True, return the configuration as a dictionary. If False, return the configuration as a pydantic model.
dict_output (bool): If True, return the configuration as a dictionary.
If False, return the configuration as a pydantic model.
Returns:
dict: The configuration of the plot widget.
dict | BaseModel: The configuration of the widget.
"""
if dict_output:
return self.config.model_dump()
else:
return self.config
# --- Example usage of BECConnector: running a simple task ---
if __name__ == "__main__": # pragma: no cover
import sys
# Create a QApplication instance (required for QThreadPool)
app = QApplication(sys.argv)
connector = BECConnector()
def print_numbers():
"""
Task function that prints numbers 1 to 10 with a 0.5 second delay between each.
"""
for i in range(1, 11):
print(i)
time.sleep(0.5)
def task_complete():
"""
Called when the task is complete.
"""
print("Task complete")
# Exit the application after the task completes.
app.quit()
# Submit the task using the connector's submit_task method.
connector.submit_task(print_numbers, on_complete=task_complete)
# Start the Qt event loop.
sys.exit(app.exec_())

View File

@@ -80,3 +80,11 @@ class BECSignalProxy(SignalProxy):
"""
if self.blocked:
self.unblock_proxy()
def cleanup(self):
"""
Cleanup the proxy by stopping the timer and disconnecting the timeout signal.
"""
self._timer.stop()
self._timer.timeout.disconnect(self._timeout_unblock)
self._timer.deleteLater()

View File

@@ -24,6 +24,7 @@ class BECWidget(BECConnector):
config: ConnectionConfig = None,
gui_id: str = None,
theme_update: bool = False,
**kwargs,
):
"""
Base class for all BEC widgets. This class should be used as a mixin class for all BEC widgets, e.g.:
@@ -44,7 +45,7 @@ class BECWidget(BECConnector):
"""
if not isinstance(self, QWidget):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
# Set the theme to auto if it is not set yet
app = QApplication.instance()
@@ -66,7 +67,7 @@ class BECWidget(BECConnector):
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme)
def _update_theme(self, theme: str):
def _update_theme(self, theme: str | None = None):
"""Update the theme."""
if theme is None:
qapp = QApplication.instance()

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import itertools
import re
from typing import TYPE_CHECKING, Literal
@@ -71,15 +70,64 @@ def apply_theme(theme: Literal["dark", "light"]):
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
"""
app = QApplication.instance()
# go through all pyqtgraph widgets and set background
children = itertools.chain.from_iterable(
top.findChildren(pg.GraphicsLayoutWidget) for top in app.topLevelWidgets()
)
pg.setConfigOptions(
foreground="d" if theme == "dark" else "k", background="k" if theme == "dark" else "w"
)
for pg_widget in children:
pg_widget.setBackground("k" if theme == "dark" else "w")
graphic_layouts = [
child
for top in app.topLevelWidgets()
for child in top.findChildren(pg.GraphicsLayoutWidget)
]
plot_items = [
item
for gl in graphic_layouts
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
if isinstance(item, pg.PlotItem)
]
histograms = [
item
for gl in graphic_layouts
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
if isinstance(item, pg.HistogramLUTItem)
]
# Update background color based on the theme
if theme == "light":
background_color = "#e9ecef" # Subtle contrast for light mode
foreground_color = "#141414"
label_color = "#000000"
axis_color = "#666666"
else:
background_color = "#141414" # Dark mode
foreground_color = "#e9ecef"
label_color = "#FFFFFF"
axis_color = "#CCCCCC"
# update GraphicsLayoutWidget
pg.setConfigOptions(foreground=foreground_color, background=background_color)
for pg_widget in graphic_layouts:
pg_widget.setBackground(background_color)
# update PlotItems
for plot_item in plot_items:
for axis in ["left", "right", "top", "bottom"]:
plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color))
plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color))
# Change title color
plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color)
# Change legend color
if hasattr(plot_item, "legend") and plot_item.legend is not None:
plot_item.legend.setLabelTextColor(label_color)
# if legend is in plot item and theme is changed, has to be like that because of pg opt logic
for sample, label in plot_item.legend.items:
label_text = label.text
label.setText(label_text, color=label_color)
# update HistogramLUTItem
for histogram in histograms:
histogram.axis.setPen(pg.mkPen(color=axis_color))
histogram.axis.setTextPen(pg.mkPen(color=label_color))
# now define stylesheet according to theme and apply it
style = bec_qthemes.load_stylesheet(theme)

View File

@@ -1 +0,0 @@

View File

@@ -30,6 +30,7 @@ from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
@@ -139,6 +140,9 @@ class BECDockArea(BECWidget, QWidget):
tooltip="Add Circular ProgressBar",
filled=True,
),
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME, tooltip="Add LogPanel", filled=True
),
},
),
"separator_2": SeparatorAction(),
@@ -200,6 +204,9 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
lambda: self.add_dock(widget="RingProgressBar", prefix="progress_bar")
)
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
lambda: self.add_dock(widget="LogPanel", prefix="log_panel")
)
# Icons
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)

View File

@@ -162,13 +162,14 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
config: Optional[FigureConfig] = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
) -> None:
if config is None:
config = FigureConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = FigureConfig(**config)
super().__init__(client=client, gui_id=gui_id)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
pg.GraphicsLayoutWidget.__init__(self, parent)
self.widget_handler = WidgetHandler()
@@ -573,10 +574,7 @@ class BECFigure(BECWidget, 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)
widget.config.row = row
widget.config.col = col
@@ -588,6 +586,7 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
self.config.num_cols = max(self.config.num_cols, col + 1)
# Saving config for future referencing
self.config.widgets[widget_id] = widget.config
self._widgets[widget_id] = widget

View File

@@ -72,7 +72,7 @@ class BECImageItem(BECConnector, pg.ImageItem):
self.config = config
else:
self.config = config
super().__init__(config=config, gui_id=gui_id)
super().__init__(config=config, gui_id=gui_id, **kwargs)
pg.ImageItem.__init__(self)
self.parent_image = parent_image

View File

@@ -338,16 +338,3 @@ class BECMultiWaveform(BECPlotBase):
Export current waveform to matplotlib GUI. Available only if matplotlib is installed in the environment.
"""
MatplotlibExporter(self.plot_item).export()
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.containers.figure import BECFigure
app = QApplication(sys.argv)
widget = BECFigure()
widget.show()
sys.exit(app.exec_())

View File

@@ -98,10 +98,11 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
config: Optional[SubplotConfig] = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
):
if config is None:
config = SubplotConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
pg.GraphicsLayout.__init__(self, parent)
self.figure = parent_figure

View File

@@ -99,11 +99,17 @@ class BECWaveform(BECPlotBase):
config: Optional[Waveform1DConfig] = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
):
if config is None:
config = Waveform1DConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
parent=parent,
parent_figure=parent_figure,
config=config,
client=client,
gui_id=gui_id,
**kwargs,
)
self._curves_data = defaultdict(dict)
@@ -120,6 +126,8 @@ class BECWaveform(BECPlotBase):
"label_suffix": "",
}
self._slice_index = None
# Scan segment update proxy
self.proxy_update_plot = pg.SignalProxy(
self.scan_signal_update, rateLimit=25, slot=self._update_scan_curves
@@ -1242,16 +1250,24 @@ class BECWaveform(BECPlotBase):
msg(dict): Message with the async data.
metadata(dict): Metadata of the message.
"""
instruction = metadata.get("async_update")
for curve in self._curves_data["async"].values():
y_name = curve.config.signals.y.name
y_data = None
x_data = None
instruction = metadata.get("async_update", {}).get("type")
max_shape = metadata.get("async_update", {}).get("max_shape", [])
all_async_curves = self._curves_data["async"].values()
# for curve in self._curves_data["async"].values():
for curve in all_async_curves:
y_entry = curve.config.signals.y.entry
x_name = self._x_axis_mode["name"]
for device, async_data in msg["signals"].items():
if device == y_entry:
data_plot = async_data["value"]
if instruction == "extend":
x_data, y_data = curve.get_data()
if instruction == "add":
if len(max_shape) > 1:
if len(data_plot.shape) > 1:
data_plot = data_plot[-1, :]
else:
x_data, y_data = curve.get_data()
if y_data is not None:
new_data = np.hstack((y_data, data_plot))
else:
@@ -1264,6 +1280,18 @@ class BECWaveform(BECPlotBase):
curve.setData(x_data, new_data)
else:
curve.setData(new_data)
elif instruction == "add_slice":
current_slice_id = metadata.get("async_update", {}).get("index")
data_plot = async_data["value"]
if current_slice_id != self._slice_index:
self._slice_index = current_slice_id
new_data = data_plot
else:
x_data, y_data = curve.get_data()
new_data = np.hstack((y_data, data_plot))
curve.setData(new_data)
elif instruction == "replace":
if x_name == "timestamp":
x_data = async_data["timestamp"]
@@ -1512,6 +1540,10 @@ class BECWaveform(BECPlotBase):
for curve_id in curve_ids_to_remove:
self.remove_curve(curve_id)
def reset(self):
self._slice_index = None
super().reset()
def clear_all(self):
sources = list(self._curves_data.keys())
for source in sources:

View File

@@ -97,7 +97,7 @@ class BECCurve(BECConnector, pg.PlotDataItem):
else:
self.config = config
# config.widget_class = self.__class__.__name__
super().__init__(config=config, gui_id=gui_id)
super().__init__(config=config, gui_id=gui_id, **kwargs)
pg.PlotDataItem.__init__(self, name=name)
self.parent_item = parent_item

View File

@@ -13,9 +13,16 @@ class AbortButton(BECWidget, QWidget):
ICON_NAME = "cancel"
def __init__(
self, parent=None, client=None, config=None, gui_id=None, toolbar=False, scan_id=None
self,
parent=None,
client=None,
config=None,
gui_id=None,
toolbar=False,
scan_id=None,
**kwargs,
):
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()

View File

@@ -12,8 +12,8 @@ class ResetButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "restart_alt"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
super().__init__(client=client, config=config, gui_id=gui_id)
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()

View File

@@ -12,8 +12,8 @@ class ResumeButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "resume"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
super().__init__(client=client, config=config, gui_id=gui_id)
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()

View File

@@ -12,8 +12,8 @@ class StopButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "dangerous"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
super().__init__(client=client, config=config, gui_id=gui_id)
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()

View File

@@ -12,8 +12,8 @@ class PositionIndicator(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "horizontal_distribute"
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.position = 50
self.min_value = 0

View File

@@ -59,7 +59,7 @@ class DeviceInputBase(BECWidget):
ReadoutPriority.ON_REQUEST: "readout_on_request",
}
def __init__(self, client=None, config=None, gui_id: str = None):
def __init__(self, client=None, config=None, gui_id: str | None = None, **kwargs):
if config is None:
config = DeviceInputConfig(widget_class=self.__class__.__name__)
@@ -67,7 +67,7 @@ class DeviceInputBase(BECWidget):
if isinstance(config, dict):
config = DeviceInputConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True)
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs)
self.get_bec_shortcuts()
self._device_filter = []
self._readout_filter = []

View File

@@ -35,14 +35,14 @@ class DeviceSignalInputBase(BECWidget):
Kind.config: "include_config_signals",
}
def __init__(self, client=None, config=None, gui_id: str = None):
def __init__(self, client=None, config=None, gui_id: str = None, **kwargs):
if config is None:
config = DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceSignalInputBaseConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
self._device = None
self.get_bec_shortcuts()

View File

@@ -45,8 +45,9 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
available_devices: list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
**kwargs,
):
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QComboBox.__init__(self, parent=parent)
if arg_name is not None:
self.config.arg_name = arg_name

View File

@@ -48,11 +48,12 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
available_devices: list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
**kwargs,
):
self._callback_id = None
self._is_valid_input = False
self._accent_colors = get_accent_colors()
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QLineEdit.__init__(self, parent=parent)
self.completer = QCompleter(self)
self.setCompleter(self.completer)

View File

@@ -38,8 +38,9 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
signal_filter: str | list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
**kwargs,
):
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QComboBox.__init__(self, parent=parent)
if arg_name is not None:
self.config.arg_name = arg_name

View File

@@ -39,9 +39,10 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
signal_filter: str | list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
**kwargs,
):
self._is_valid_input = False
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QLineEdit.__init__(self, parent=parent)
self._accent_colors = get_accent_colors()
self.completer = QCompleter(self)

View File

@@ -1,10 +1,10 @@
from collections import defaultdict
from types import SimpleNamespace
from types import NoneType, SimpleNamespace
from typing import Optional
from bec_lib.endpoints import MessageEndpoints
from pydantic import BaseModel, Field
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtCore import Signal
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QApplication,
@@ -18,12 +18,13 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
@@ -42,6 +43,7 @@ class ScanControlConfig(ConnectionConfig):
class ScanControl(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "tune"
ARG_BOX_POSITION: int = 2
scan_started = Signal()
scan_selected = Signal(str)
@@ -56,13 +58,14 @@ class ScanControl(BECWidget, QWidget):
gui_id: str | None = None,
allowed_scans: list | None = None,
default_scan: str | None = None,
**kwargs,
):
if config is None:
config = ScanControlConfig(
widget_class=self.__class__.__name__, allowed_scans=allowed_scans
)
super().__init__(client=client, gui_id=gui_id, config=config)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
QWidget.__init__(self, parent=parent)
self._hide_add_remove_buttons = False
@@ -83,6 +86,8 @@ class ScanControl(BECWidget, QWidget):
self.config.default_scan = default_scan
self.config.allowed_scans = allowed_scans
self._scan_metadata: dict | None = None
# Create and set main layout
self._init_UI()
@@ -152,6 +157,20 @@ class ScanControl(BECWidget, QWidget):
# Initialize scan selection
self.populate_scans()
# Append metadata form
self._add_metadata_form()
self.layout.addStretch()
def _add_metadata_form(self):
self._metadata_form = ScanMetadata()
self.layout.addWidget(self._metadata_form)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
self._metadata_form.metadata_updated.connect(self.update_scan_metadata)
self._metadata_form.metadata_cleared.connect(self.update_scan_metadata)
self._metadata_form.validate_form()
def populate_scans(self):
"""Populates the scan selection combo box with available scans from BEC session."""
self.available_scans = self.client.connector.get(
@@ -176,8 +195,9 @@ class ScanControl(BECWidget, QWidget):
self.request_last_executed_scan_parameters()
self.restore_scan_parameters(selected_scan_name)
@Slot()
def request_last_executed_scan_parameters(self):
@SafeSlot()
@SafeSlot(bool)
def request_last_executed_scan_parameters(self, *_):
"""
Requests the last executed scan parameters from BEC and restores them to the scan control widget.
"""
@@ -211,7 +231,7 @@ class ScanControl(BECWidget, QWidget):
else:
self.last_scan_found = False
@Property(str)
@SafeProperty(str)
def current_scan(self):
"""Returns the scan name for the currently selected scan."""
return self.comboBox_scan_selection.currentText()
@@ -227,7 +247,7 @@ class ScanControl(BECWidget, QWidget):
return
self.comboBox_scan_selection.setCurrentText(scan_name)
@Slot(str)
@SafeSlot(str)
def set_current_scan(self, scan_name: str):
"""Slot for setting the current scan to the given scan name.
@@ -236,7 +256,7 @@ class ScanControl(BECWidget, QWidget):
"""
self.current_scan = scan_name
@Property(bool)
@SafeProperty(bool)
def hide_arg_box(self):
"""Property to hide the argument box."""
if self.arg_box is None:
@@ -253,7 +273,7 @@ class ScanControl(BECWidget, QWidget):
if self.arg_box is not None:
self.arg_box.setVisible(not hide)
@Property(bool)
@SafeProperty(bool)
def hide_kwarg_boxes(self):
"""Property to hide the keyword argument boxes."""
if len(self.kwarg_boxes) == 0:
@@ -274,7 +294,7 @@ class ScanControl(BECWidget, QWidget):
for box in self.kwarg_boxes:
box.setVisible(not hide)
@Property(bool)
@SafeProperty(bool)
def hide_scan_control_buttons(self):
"""Property to hide the scan control buttons."""
return not self.button_run_scan.isVisible()
@@ -288,12 +308,40 @@ class ScanControl(BECWidget, QWidget):
"""
self.show_scan_control_buttons(not hide)
@Slot(bool)
@SafeProperty(bool)
def hide_metadata(self):
"""Property to hide the metadata form."""
return not self._metadata_form.isVisible()
@hide_metadata.setter
def hide_metadata(self, hide: bool):
"""Setter for the hide_metadata property.
Args:
hide(bool): Hide or show the metadata form.
"""
self._metadata_form.setVisible(not hide)
@SafeProperty(bool)
def hide_optional_metadata(self):
"""Property to hide the optional metadata form."""
return self._metadata_form.hide_optional_metadata
@hide_optional_metadata.setter
def hide_optional_metadata(self, hide: bool):
"""Setter for the hide_optional_metadata property.
Args:
hide(bool): Hide or show the optional metadata form.
"""
self._metadata_form.hide_optional_metadata = hide
@SafeSlot(bool)
def show_scan_control_buttons(self, show: bool):
"""Shows or hides the scan control buttons."""
self.scan_control_group.setVisible(show)
@Property(bool)
@SafeProperty(bool)
def hide_scan_selection_combobox(self):
"""Property to hide the scan selection combobox."""
return not self.comboBox_scan_selection.isVisible()
@@ -307,12 +355,12 @@ class ScanControl(BECWidget, QWidget):
"""
self.show_scan_selection_combobox(not hide)
@Slot(bool)
@SafeSlot(bool)
def show_scan_selection_combobox(self, show: bool):
"""Shows or hides the scan selection combobox."""
self.scan_selection_group.setVisible(show)
@Slot(str)
@SafeSlot(str)
def scan_select(self, scan_name: str):
"""
Slot for scan selection. Updates the scan control layout based on the selected scan.
@@ -335,7 +383,7 @@ class ScanControl(BECWidget, QWidget):
self.update()
self.adjustSize()
@Property(bool)
@SafeProperty(bool)
def hide_add_remove_buttons(self):
"""Property to hide the add_remove buttons."""
return self._hide_add_remove_buttons
@@ -358,10 +406,11 @@ class ScanControl(BECWidget, QWidget):
Args:
groups(list): List of dictionaries containing the gui_group information.
"""
position = self.ARG_BOX_POSITION + (1 if self.arg_box is not None else 0)
for group in groups:
box = ScanGroupBox(box_type="kwargs", config=group)
box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.layout.addWidget(box)
self.layout.insertWidget(position + len(self.kwarg_boxes), box)
self.kwarg_boxes.append(box)
def add_arg_group(self, group: dict):
@@ -374,9 +423,9 @@ class ScanControl(BECWidget, QWidget):
self.arg_box.device_selected.connect(self.emit_device_selected)
self.arg_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.arg_box.hide_add_remove_buttons = self._hide_add_remove_buttons
self.layout.addWidget(self.arg_box)
self.layout.insertWidget(self.ARG_BOX_POSITION, self.arg_box)
@Slot(str)
@SafeSlot(str)
def emit_device_selected(self, dev_names):
"""
Emit the signal to inform about selected device(s)
@@ -454,10 +503,20 @@ class ScanControl(BECWidget, QWidget):
scan_params = ScanParameterConfig(name=scan_name, args=args, kwargs=kwargs)
self.config.scans[scan_name] = scan_params
@SafeSlot(dict)
@SafeSlot(NoneType)
def update_scan_metadata(self, md: dict | None):
self._scan_metadata = md
if md is None:
self.button_run_scan.setEnabled(False)
else:
self.button_run_scan.setEnabled(True)
@SafeSlot(popup_error=True)
def run_scan(self):
"""Starts the selected scan with the given parameters."""
args, kwargs = self.get_scan_parameters()
kwargs["metadata"] = self._scan_metadata
self.scan_args.emit(args)
scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText())
if callable(scan_function):

View File

@@ -37,9 +37,14 @@ class DapComboBox(BECWidget, QWidget):
fit_model_updated = Signal(str)
def __init__(
self, parent=None, client=None, gui_id: str | None = None, default_fit: str | None = None
self,
parent=None,
client=None,
gui_id: str | None = None,
default_fit: str | None = None,
**kwargs,
):
super().__init__(client=client, gui_id=gui_id)
super().__init__(client=client, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.layout = QVBoxLayout(self)
self.fit_model_combobox = QComboBox(self)

View File

@@ -30,6 +30,7 @@ class LMFitDialog(BECWidget, QWidget):
target_widget=None,
gui_id: str | None = None,
ui_file="lmfit_dialog_vertical.ui",
**kwargs,
):
"""
Initialises the LMFitDialog widget.
@@ -42,7 +43,7 @@ class LMFitDialog(BECWidget, QWidget):
gui_id (str): GUI ID.
ui_file (str): The UI file to be loaded.
"""
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.setProperty("skip_settings", True)
self.setObjectName("LMFitDialog")

View File

@@ -0,0 +1,7 @@
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
AdditionalMetadataTable,
AdditionalMetadataTableModel,
)
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
__all__ = ["ScanMetadata", "AdditionalMetadataTable", "AdditionalMetadataTableModel"]

View File

@@ -0,0 +1,275 @@
from __future__ import annotations
from abc import abstractmethod
from decimal import Decimal
from typing import TYPE_CHECKING, Callable, get_args
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, Field
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
QButtonGroup,
QCheckBox,
QDoubleSpinBox,
QGridLayout,
QHBoxLayout,
QLabel,
QLayout,
QLineEdit,
QRadioButton,
QSpinBox,
QToolButton,
QWidget,
)
from bec_widgets.widgets.editors.scan_metadata._util import (
clearable_required,
field_default,
field_limits,
field_maxlen,
field_minlen,
field_precision,
)
if TYPE_CHECKING:
from pydantic.fields import FieldInfo
logger = bec_logger.logger
class ClearableBoolEntry(QWidget):
stateChanged = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._layout = QHBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
self._entry = QButtonGroup()
self._true = QRadioButton("true", parent=self)
self._false = QRadioButton("false", parent=self)
for button in [self._true, self._false]:
self._layout.addWidget(button)
self._entry.addButton(button)
button.toggled.connect(self.stateChanged)
def clear(self):
self._entry.setExclusive(False)
self._true.setChecked(False)
self._false.setChecked(False)
self._entry.setExclusive(True)
def isChecked(self) -> bool | None:
if not self._true.isChecked() and not self._false.isChecked():
return None
return self._true.isChecked()
def setChecked(self, value: bool | None):
if value is None:
self.clear()
elif value:
self._true.setChecked(True)
self._false.setChecked(False)
else:
self._true.setChecked(False)
self._false.setChecked(True)
def setToolTip(self, tooltip: str):
self._true.setToolTip(tooltip)
self._false.setToolTip(tooltip)
class MetadataWidget(QWidget):
valueChanged = Signal()
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._info = info
self._layout = QHBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize)
self._default = field_default(self._info)
self._desc = self._info.description
self.setLayout(self._layout)
self._add_main_widget()
if clearable_required(info):
self._add_clear_button()
@abstractmethod
def getValue(self): ...
@abstractmethod
def setValue(self, value): ...
@abstractmethod
def _add_main_widget(self) -> None:
"""Add the main data entry widget to self._main_widget and appply any
constraints from the field info"""
def _describe(self, pad=" "):
return pad + (self._desc if self._desc else "")
def _add_clear_button(self):
self._clear_button = QToolButton()
self._clear_button.setIcon(
material_icon(icon_name="close", size=(10, 10), convert_to_pixmap=False)
)
self._layout.addWidget(self._clear_button)
# the widget added in _add_main_widget must implement .clear() if value is not required
self._clear_button.setToolTip("Clear value or reset to default.")
self._clear_button.clicked.connect(self._main_widget.clear) # type: ignore
def _value_changed(self, *_, **__):
self.valueChanged.emit()
class StrMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = QLineEdit()
self._layout.addWidget(self._main_widget)
min_length, max_length = field_minlen(self._info), field_maxlen(self._info)
if max_length:
self._main_widget.setMaxLength(max_length)
self._main_widget.setToolTip(
f"(length min: {min_length} max: {max_length}){self._describe()}"
)
if self._default:
self._main_widget.setText(self._default)
self._add_clear_button()
def getValue(self):
if self._main_widget.text() == "":
return self._default
return self._main_widget.text()
def setValue(self, value: str):
if value is None:
self._main_widget.setText("")
self._main_widget.setText(value)
class IntMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = QSpinBox()
self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._info, int)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
self._main_widget.setToolTip(f"(range {min_} to {max_}){self._describe()}")
if self._default is not None:
self._main_widget.setValue(self._default)
self._add_clear_button()
else:
self._main_widget.clear()
def getValue(self):
if self._main_widget.text() == "":
return self._default
return self._main_widget.value()
def setValue(self, value: int):
if value is None:
self._main_widget.clear()
self._main_widget.setValue(value)
class FloatDecimalMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = QDoubleSpinBox()
self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._info, int)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
precision = field_precision(self._info)
if precision:
self._main_widget.setDecimals(precision)
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
maxstr = f"{float(max_):.3f}" if abs(max_) <= 1000 else f"{float(max_):.3e}"
self._main_widget.setToolTip(f"(range {minstr} to {maxstr}){self._describe()}")
if self._default is not None:
self._main_widget.setValue(self._default)
self._add_clear_button()
else:
self._main_widget.clear()
def getValue(self):
if self._main_widget.text() == "":
return self._default
return self._main_widget.value()
def setValue(self, value: float):
if value is None:
self._main_widget.clear()
self._main_widget.setValue(value)
class BoolMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
self._main_widget.stateChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
if clearable_required(self._info):
self._main_widget = ClearableBoolEntry()
else:
self._main_widget = QCheckBox()
self._layout.addWidget(self._main_widget)
self._main_widget.setToolTip(self._describe(""))
self._main_widget.setChecked(self._default) # type: ignore # if there is no default then it will be ClearableBoolEntry and can be set with None
def getValue(self):
return self._main_widget.isChecked()
def setValue(self, value):
self._main_widget.setChecked(value)
def widget_from_type(annotation: type | None) -> Callable[[FieldInfo], MetadataWidget]:
if annotation in [str, str | None]:
return StrMetadataField
if annotation in [int, int | None]:
return IntMetadataField
if annotation in [float, float | None, Decimal, Decimal | None]:
return FloatDecimalMetadataField
if annotation in [bool, bool | None]:
return BoolMetadataField
else:
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
return StrMetadataField
if __name__ == "__main__": # pragma: no cover
class TestModel(BaseModel):
value1: str | None = Field(None)
value2: bool | None = Field(None)
value3: bool = Field(True)
value4: int = Field(123)
value5: int | None = Field()
app = QApplication([])
w = QWidget()
layout = QGridLayout()
w.setLayout(layout)
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
layout.addWidget(QLabel(field_name), i, 0)
layout.addWidget(widget_from_type(info.annotation)(info), i, 1)
w.show()
app.exec()

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import sys
from decimal import Decimal
from math import inf, nextafter
from typing import TYPE_CHECKING, TypeVar, get_args
from annotated_types import Ge, Gt, Le, Lt
from bec_lib.logger import bec_logger
from pydantic_core import PydanticUndefined
if TYPE_CHECKING:
from pydantic.fields import FieldInfo
logger = bec_logger.logger
_MININT = -2147483648
_MAXINT = 2147483647
_MINFLOAT = -sys.float_info.max
_MAXFLOAT = sys.float_info.max
T = TypeVar("T", int, float, Decimal)
def field_limits(info: FieldInfo, type_: type[T]) -> tuple[T, T]:
_min = _MININT if type_ is int else _MINFLOAT
_max = _MAXINT if type_ is int else _MAXFLOAT
for md in info.metadata:
if isinstance(md, Ge):
_min = type_(md.ge) # type: ignore
if isinstance(md, Gt):
_min = type_(md.gt) + 1 if type_ is int else nextafter(type_(md.gt), inf) # type: ignore
if isinstance(md, Lt):
_max = type_(md.lt) - 1 if type_ is int else nextafter(type_(md.lt), -inf) # type: ignore
if isinstance(md, Le):
_max = type_(md.le) # type: ignore
return _min, _max # type: ignore
def _get_anno(info: FieldInfo, annotation: str, default):
for md in info.metadata:
if hasattr(md, annotation):
return getattr(md, annotation)
return default
def field_precision(info: FieldInfo):
return _get_anno(info, "decimal_places", 307)
def field_maxlen(info: FieldInfo):
return _get_anno(info, "max_length", None)
def field_minlen(info: FieldInfo):
return _get_anno(info, "min_length", None)
def field_default(info: FieldInfo):
if info.default is PydanticUndefined:
return
return info.default
def clearable_required(info: FieldInfo):
return type(None) in get_args(info.annotation) or info.is_required()

View File

@@ -0,0 +1,149 @@
from __future__ import annotations
from typing import Any
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
QHBoxLayout,
QPushButton,
QSizePolicy,
QTreeView,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.error_popups import SafeSlot
class AdditionalMetadataTableModel(QAbstractTableModel):
def __init__(self, data):
super().__init__()
self._data: list[list[str]] = data
self._disallowed_keys: list[str] = []
def headerData(
self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole()
) -> Any:
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
return "Key" if section == 0 else "Value"
return super().headerData(section, orientation, role)
def rowCount(self, index: QModelIndex = QModelIndex()):
return 0 if index.isValid() else len(self._data)
def columnCount(self, index: QModelIndex = QModelIndex()):
return 0 if index.isValid() else 2
def data(self, index, role=Qt.ItemDataRole):
if index.isValid():
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
return str(self._data[index.row()][index.column()])
def setData(self, index, value, role):
if role == Qt.ItemDataRole.EditRole:
if value in self._disallowed_keys or value in self._other_keys(index.row()):
return False
self._data[index.row()][index.column()] = str(value)
return True
return False
def update_disallowed_keys(self, keys: list[str]):
self._disallowed_keys = keys
for i, item in enumerate(self._data):
if item[0] in self._disallowed_keys:
self._data[i][0] = ""
self.dataChanged.emit(self.index(i, 0), self.index(i, 0))
def _other_keys(self, row: int):
return [r[0] for r in self._data[:row] + self._data[row + 1 :]]
def flags(self, _):
return Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsEditable
def insertRows(self, row, number, index):
"""We only support adding one at a time for now"""
if row != self.rowCount() or number != 1:
return False
self.beginInsertRows(QModelIndex(), 0, 0)
self._data.append(["", ""])
self.endInsertRows()
return True
def removeRows(self, row, number, index):
"""This can only be consecutive, so instead of trying to be clever, only support removing one at a time"""
if number != 1:
return False
self.beginRemoveRows(QModelIndex(), row, row)
del self._data[row]
self.endRemoveRows()
return True
@SafeSlot()
def add_row(self):
self.insertRow(self.rowCount())
@SafeSlot(list)
def delete_rows(self, rows: list[int]):
# delete from the end so indices stay correct
for row in sorted(rows, reverse=True):
self.removeRows(row, 1, QModelIndex())
def dump_dict(self):
if self._data == [[]]:
return {}
return dict(self._data)
class AdditionalMetadataTable(QWidget):
delete_rows = Signal(list)
def __init__(self, initial_data: list[list[str]]):
super().__init__()
self._layout = QHBoxLayout()
self.setLayout(self._layout)
self._table_model = AdditionalMetadataTableModel(initial_data)
self._table_view = QTreeView()
self._table_view.setModel(self._table_model)
self._table_view.setSizePolicy(
QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
)
self._table_view.setAlternatingRowColors(True)
self._layout.addWidget(self._table_view)
self._buttons = QVBoxLayout()
self._layout.addLayout(self._buttons)
self._add_button = QPushButton("+")
self._add_button.setToolTip("add a new row")
self._remove_button = QPushButton("-")
self._remove_button.setToolTip("delete rows containing any selected cells")
self._buttons.addWidget(self._add_button)
self._buttons.addWidget(self._remove_button)
self._add_button.clicked.connect(self._table_model.add_row)
self._remove_button.clicked.connect(self.delete_selected_rows)
self.delete_rows.connect(self._table_model.delete_rows)
def delete_selected_rows(self):
cells: list[QModelIndex] = self._table_view.selectionModel().selectedIndexes()
row_indices = list({r.row() for r in cells})
if row_indices:
self.delete_rows.emit(row_indices)
def dump_dict(self):
return self._table_model.dump_dict()
def update_disallowed_keys(self, keys: list[str]):
self._table_model.update_disallowed_keys(keys)
if __name__ == "__main__": # pragma: no cover
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("dark")
window = AdditionalMetadataTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window.show()
app.exec()

View File

@@ -0,0 +1,234 @@
from __future__ import annotations
from decimal import Decimal
from types import NoneType
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from bec_lib.metadata_schema import get_metadata_schema_for_scan
from bec_qthemes import material_icon
from pydantic import Field, ValidationError
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QGridLayout,
QHBoxLayout,
QLabel,
QLayout,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.qt_utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import widget_from_type
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
AdditionalMetadataTable,
)
if TYPE_CHECKING:
from pydantic.fields import FieldInfo
logger = bec_logger.logger
class ScanMetadata(BECWidget, QWidget):
"""Dynamically generates a form for inclusion of metadata for a scan. Uses the
metadata schema registry supplied in the plugin repo to find pydantic models
associated with the scan type. Sets limits for numerical values if specified."""
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
def __init__(
self,
parent=None,
client=None,
scan_name: str | None = None,
initial_extras: list[list[str]] | None = None,
**kwargs,
):
super().__init__(client=client, **kwargs)
QWidget.__init__(self, parent=parent)
self.set_schema(scan_name)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._required_md_box = ExpandableGroupFrame("Scan schema metadata")
self._layout.addWidget(self._required_md_box)
self._required_md_box_layout = QHBoxLayout()
self._required_md_box.set_layout(self._required_md_box_layout)
self._md_grid = QWidget()
self._required_md_box_layout.addWidget(self._md_grid)
self._grid_container = QVBoxLayout()
self._md_grid.setLayout(self._grid_container)
self._new_grid_layout()
self._grid_container.addLayout(self._md_grid_layout)
self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False)
self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout = QHBoxLayout()
self._additional_md_box.set_layout(self._additional_md_box_layout)
self._additional_metadata = AdditionalMetadataTable(initial_extras or [])
self._additional_md_box_layout.addWidget(self._additional_metadata)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
self._validity.label = "Metadata validity" # type: ignore
self._validity.compact_show_popup.setIcon(
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
)
self._validity_message = QLabel("Not yet validated")
self._validity.addWidget(self._validity_message)
self._layout.addWidget(self._validity)
self.populate()
@SafeSlot(str)
def update_with_new_scan(self, scan_name: str):
self.set_schema(scan_name)
self.populate()
self.validate_form()
def validate_form(self, *_) -> bool:
"""validate the currently entered metadata against the pydantic schema.
If successful, returns on metadata_emitted and returns true.
Otherwise, emits on metadata_cleared and returns false."""
try:
metadata_dict = self.get_full_model_dict()
self._md_schema.model_validate(metadata_dict)
self._validity.set_global_state("success")
self._validity_message.setText("No errors!")
self.metadata_updated.emit(metadata_dict)
except ValidationError as e:
self._validity.set_global_state("emergency")
self._validity_message.setText(str(e))
self.metadata_cleared.emit(None)
def get_full_model_dict(self):
"""Get the entered metadata as a dict"""
return self._additional_metadata.dump_dict() | self._dict_from_grid()
def set_schema(self, scan_name: str | None = None):
self._scan_name = scan_name or ""
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
def populate(self):
self._clear_grid()
self._populate()
def _populate(self):
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
for i, (field_name, info) in enumerate(self._md_schema.model_fields.items()):
self._add_griditem(field_name, info, i)
def _add_griditem(self, field_name: str, info: FieldInfo, row: int):
grid = self._md_grid_layout
label = QLabel(info.title or field_name)
label.setProperty("_model_field_name", field_name)
label.setToolTip(info.description or field_name)
grid.addWidget(label, row, 0)
widget = widget_from_type(info.annotation)(info)
widget.valueChanged.connect(self.validate_form)
grid.addWidget(widget, row, 1)
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
grid = self._md_grid_layout
return {
grid.itemAtPosition(i, 0).widget().property("_model_field_name"): grid.itemAtPosition(i, 1).widget().getValue() # type: ignore # we only add 'MetadataWidget's here
for i in range(grid.rowCount())
}
def _clear_grid(self):
while self._md_grid_layout.count():
item = self._md_grid_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
self._md_grid_layout.deleteLater()
self._new_grid_layout()
self._grid_container.addLayout(self._md_grid_layout)
self._md_grid.adjustSize()
self.adjustSize()
def _new_grid_layout(self):
self._md_grid_layout = QGridLayout()
self._md_grid_layout.setContentsMargins(0, 0, 0, 0)
self._md_grid_layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
@SafeProperty(bool)
def hide_optional_metadata(self): # type: ignore
"""Property to hide the optional metadata table."""
return not self._additional_md_box.isVisible()
@hide_optional_metadata.setter
def hide_optional_metadata(self, hide: bool):
"""Setter for the hide_optional_metadata property.
Args:
hide(bool): Hide or show the optional metadata table.
"""
self._additional_md_box.setVisible(not hide)
if __name__ == "__main__": # pragma: no cover
from unittest.mock import patch
from bec_lib.metadata_schema import BasicScanMetadata
from bec_widgets.utils.colors import set_theme
class ExampleSchema1(BasicScanMetadata):
abc: int = Field(gt=0, lt=2000, description="Heating temperature abc", title="A B C")
foo: str = Field(max_length=12, description="Sample database code", default="DEF123")
xyz: Decimal = Field(decimal_places=4)
baz: bool
class ExampleSchema2(BasicScanMetadata):
checkbox_up_top: bool
checkbox_again: bool = Field(
title="Checkbox Again", description="this one defaults to True", default=True
)
different_items: int | None = Field(
None, description="This is just one different item...", gt=-100, lt=0
)
length_limited_string: str = Field(max_length=32)
float_with_2dp: Decimal = Field(decimal_places=2)
class ExampleSchema3(BasicScanMetadata):
optional_with_regex: str | None = Field(None, pattern=r"^\d+-\d+$")
with patch(
"bec_lib.metadata_schema._get_metadata_schema_registry",
lambda: {"scan1": ExampleSchema1, "scan2": ExampleSchema2, "scan3": ExampleSchema3},
):
app = QApplication([])
w = QWidget()
selection = QComboBox()
selection.addItems(["grid_scan", "scan1", "scan2", "scan3"])
layout = QVBoxLayout()
w.setLayout(layout)
scan_metadata = ScanMetadata(
scan_name="grid_scan",
initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]],
)
selection.currentTextChanged.connect(scan_metadata.update_with_new_scan)
layout.addWidget(selection)
layout.addWidget(scan_metadata)
set_theme("dark")
window = w
window.show()
app.exec()

View File

@@ -5,9 +5,9 @@ from html.parser import HTMLParser
from bec_lib.logger import bec_logger
from pydantic import Field
from qtpy.QtCore import Property, Slot
from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
@@ -42,14 +42,14 @@ class TextBox(BECWidget, QWidget):
USER_ACCESS = ["set_plain_text", "set_html_text"]
ICON_NAME = "chat"
def __init__(self, parent=None, client=None, config=None, gui_id=None):
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
if config is None:
config = TextBoxConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = TextBoxConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
self.layout = QVBoxLayout(self)
self.text_box_text_edit = QTextEdit(parent=self)
@@ -66,7 +66,7 @@ class TextBox(BECWidget, QWidget):
else:
self.set_html_text(DEFAULT_TEXT)
@Slot(str)
@SafeSlot(str)
def set_plain_text(self, text: str) -> None:
"""Set the plain text of the widget.
@@ -77,7 +77,7 @@ class TextBox(BECWidget, QWidget):
self.config.text = text
self.config.is_html = False
@Slot(str)
@SafeSlot(str)
def set_html_text(self, text: str) -> None:
"""Set the HTML text of the widget.
@@ -88,7 +88,7 @@ class TextBox(BECWidget, QWidget):
self.config.text = text
self.config.is_html = True
@Property(str)
@SafeProperty(str)
def plain_text(self) -> str:
"""Get the text of the widget.
@@ -106,7 +106,7 @@ class TextBox(BECWidget, QWidget):
"""
self.set_plain_text(text)
@Property(str)
@SafeProperty(str)
def html_text(self) -> str:
"""Get the HTML text of the widget.

View File

@@ -45,12 +45,12 @@ class VSCodeEditor(WebsiteWidget):
USER_ACCESS = []
ICON_NAME = "developer_mode_tv"
def __init__(self, parent=None, config=None, client=None, gui_id=None):
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
self.process = None
self.port = get_free_port()
self._url = f"http://{self.host}:{self.port}?tkn={self.token}"
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id)
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id, **kwargs)
self.start_server()
self.bec_dispatcher.connect_slot(self.on_vscode_event, f"vscode-events/{self.gui_id}")

View File

@@ -23,8 +23,10 @@ class WebsiteWidget(BECWidget, QWidget):
ICON_NAME = "travel_explore"
USER_ACCESS = ["set_url", "get_url", "reload", "back", "forward"]
def __init__(self, parent=None, url: str = None, config=None, client=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
def __init__(
self, parent=None, url: str = None, config=None, client=None, gui_id=None, **kwargs
):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)

View File

@@ -54,13 +54,14 @@ class BECImageWidget(BECWidget, QWidget):
config: ImageConfig | dict = None,
client=None,
gui_id: str | None = None,
**kwargs,
) -> None:
if config is None:
config = ImageConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = ImageConfig(**config)
super().__init__(client=client, gui_id=gui_id)
super().__init__(client=client, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
self.layout = QVBoxLayout(self)
self.layout.setSpacing(0)

View File

@@ -35,13 +35,14 @@ class BECMotorMapWidget(BECWidget, QWidget):
config: MotorMapConfig | None = None,
client=None,
gui_id: str | None = None,
**kwargs,
) -> None:
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)
super().__init__(client=client, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
self.layout = QVBoxLayout(self)

View File

@@ -63,13 +63,14 @@ class BECMultiWaveformWidget(BECWidget, QWidget):
config: BECMultiWaveformConfig | dict = None,
client=None,
gui_id: str | None = None,
**kwargs,
) -> None:
if config is None:
config = BECMultiWaveformConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = BECMultiWaveformConfig(**config)
super().__init__(client=client, gui_id=gui_id)
super().__init__(client=client, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
self.layout = QVBoxLayout(self)

View File

@@ -86,13 +86,14 @@ class BECWaveformWidget(BECWidget, QWidget):
config: Waveform1DConfig | dict = None,
client=None,
gui_id: str | None = None,
**kwargs,
) -> None:
if config is None:
config = Waveform1DConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = Waveform1DConfig(**config)
super().__init__(client=client, gui_id=gui_id)
super().__init__(client=client, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
self.layout = QVBoxLayout(self)

View File

@@ -1,17 +1,20 @@
from __future__ import annotations
from enum import Enum
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger
from qtpy.QtCore import QPoint, QPointF, Qt, Signal
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.qt_utils.round_frame import RoundedFrame
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
from bec_widgets.qt_utils.side_panel import SidePanel
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar, SeparatorAction
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar, ToolbarBundle
from bec_widgets.utils import ConnectionConfig, Crosshair, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.fps_counter import FPSCounter
from bec_widgets.utils.widget_state_manager import WidgetStateManager
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
@@ -20,8 +23,7 @@ from bec_widgets.widgets.plots_next_gen.toolbar_bundles.mouse_interactions impor
MouseInteractionToolbarBundle,
)
from bec_widgets.widgets.plots_next_gen.toolbar_bundles.plot_export import PlotExportBundle
from bec_widgets.widgets.plots_next_gen.toolbar_bundles.save_state import SaveStateBundle
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
from bec_widgets.widgets.plots_next_gen.toolbar_bundles.roi_bundle import ROIBundle
logger = bec_logger.logger
@@ -43,6 +45,12 @@ class BECViewBox(pg.ViewBox):
self.update()
class UIMode(Enum):
NONE = 0
POPUP = 1
SIDE = 2
class PlotBase(BECWidget, QWidget):
PLUGIN = False
RPC = False
@@ -60,10 +68,12 @@ class PlotBase(BECWidget, QWidget):
config: ConnectionConfig | None = None,
client=None,
gui_id: str | None = None,
popups: bool = False,
**kwargs,
) -> None:
if config is None:
config = ConnectionConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, gui_id=gui_id, config=config)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
QWidget.__init__(self, parent=parent)
# For PropertyManager identification
@@ -75,6 +85,8 @@ class PlotBase(BECWidget, QWidget):
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(0)
self.layout_manager = LayoutManagerWidget(parent=self)
self.layout_manager.layout.setContentsMargins(0, 0, 0, 0)
self.layout_manager.layout.setSpacing(0)
# Property Manager
self.state_manager = WidgetStateManager(self)
@@ -83,24 +95,34 @@ class PlotBase(BECWidget, QWidget):
self.entry_validator = EntryValidator(self.dev)
# Base widgets elements
self._ui_mode = UIMode.POPUP if popups else UIMode.SIDE
self.axis_settings_dialog = None
self.plot_widget = pg.GraphicsLayoutWidget(parent=self)
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
self.plot_widget = pg.PlotWidget(plotItem=self.plot_item)
self.plot_widget.addItem(self.plot_item)
self.side_panel = SidePanel(self, orientation="left", panel_max_width=280)
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
self.init_toolbar()
self._init_toolbar()
# PlotItem Addons
self.plot_item.addLegend()
self.crosshair = None
self.fps_monitor = None
self.fps_label = QLabel(alignment=Qt.AlignmentFlag.AlignRight)
self._user_x_label = ""
self._x_label_suffix = ""
self._init_ui()
self._connect_to_theme_change()
self._update_theme()
def apply_theme(self, theme: str):
self.round_plot_widget.apply_theme(theme)
def _init_ui(self):
self.layout.addWidget(self.layout_manager)
self.round_plot_widget = RoundedFrame(content_widget=self.plot_widget, theme_update=True)
self.round_plot_widget.apply_theme("dark")
self.layout_manager.add_widget(self.round_plot_widget)
self.layout_manager.add_widget_relative(self.fps_label, self.round_plot_widget, "top")
@@ -108,56 +130,149 @@ class PlotBase(BECWidget, QWidget):
self.layout_manager.add_widget_relative(self.side_panel, self.round_plot_widget, "left")
self.layout_manager.add_widget_relative(self.toolbar, self.fps_label, "top")
self.add_side_menus()
self.ui_mode = self._ui_mode # to initiate the first time
# PlotItem ViewBox Signals
self.plot_item.vb.sigStateChanged.connect(self.viewbox_state_changed)
def init_toolbar(self):
def _init_toolbar(self):
self.popup_bundle = None
self.performance_bundle = ToolbarBundle("performance")
self.plot_export_bundle = PlotExportBundle("plot_export", target_widget=self)
self.mouse_bundle = MouseInteractionToolbarBundle("mouse_interaction", target_widget=self)
self.state_export_bundle = SaveStateBundle("state_export", target_widget=self)
# self.state_export_bundle = SaveStateBundle("state_export", target_widget=self) #TODO ATM disabled, cannot be used in DockArea, which is exposed to the user
self.roi_bundle = ROIBundle("roi", target_widget=self)
# Add elements to toolbar
self.toolbar.add_bundle(self.plot_export_bundle, target_widget=self)
self.toolbar.add_bundle(self.state_export_bundle, target_widget=self)
# self.toolbar.add_bundle(self.state_export_bundle, target_widget=self) #TODO ATM disabled, cannot be used in DockArea, which is exposed to the user
self.toolbar.add_bundle(self.mouse_bundle, target_widget=self)
self.toolbar.add_bundle(self.roi_bundle, target_widget=self)
self.toolbar.add_action("separator_0", SeparatorAction(), target_widget=self)
self.toolbar.add_action(
"crosshair",
MaterialIconAction(icon_name="point_scan", tooltip="Show Crosshair", checkable=True),
target_widget=self,
)
self.toolbar.add_action("separator_1", SeparatorAction(), target_widget=self)
self.toolbar.add_action(
self.performance_bundle.add_action(
"fps_monitor",
MaterialIconAction(icon_name="speed", tooltip="Show FPS Monitor", checkable=True),
target_widget=self,
MaterialIconAction(
icon_name="speed", tooltip="Show FPS Monitor", checkable=True, parent=self
),
)
self.toolbar.addWidget(DarkModeButton(toolbar=True))
self.toolbar.add_bundle(self.performance_bundle, target_widget=self)
self.toolbar.widgets["fps_monitor"].action.toggled.connect(
lambda checked: setattr(self, "enable_fps_monitor", checked)
)
self.toolbar.widgets["crosshair"].action.toggled.connect(self.toggle_crosshair)
# hide some options by default
self.toolbar.toggle_action_visibility("fps_monitor", False)
def add_side_menus(self):
"""Adds multiple menus to the side panel."""
# Setting Axis Widget
axis_setting = AxisSettings(target_widget=self)
self.side_panel.add_menu(
action_id="axis",
icon_name="settings",
tooltip="Show Axis Settings",
widget=axis_setting,
title="Axis Settings",
try:
axis_setting = AxisSettings(target_widget=self)
self.side_panel.add_menu(
action_id="axis",
icon_name="settings",
tooltip="Show Axis Settings",
widget=axis_setting,
title="Axis Settings",
)
except ValueError:
return
def add_popups(self):
"""
Add popups to the toolbar.
"""
self.popup_bundle = ToolbarBundle("popup_bundle")
settings = MaterialIconAction(
icon_name="settings", tooltip="Show Axis Settings", checkable=True, parent=self
)
self.popup_bundle.add_action("axis", settings)
self.toolbar.add_bundle(self.popup_bundle, target_widget=self)
self.toolbar.widgets["axis"].action.triggered.connect(self.show_axis_settings_popup)
def show_axis_settings_popup(self):
"""
Show the axis settings dialog.
"""
settings_action = self.toolbar.widgets["axis"].action
if self.axis_settings_dialog is None or not self.axis_settings_dialog.isVisible():
axis_setting = AxisSettings(target_widget=self, popup=True)
self.axis_settings_dialog = SettingsDialog(
self, settings_widget=axis_setting, window_title="Axis Settings", modal=False
)
# When the dialog is closed, update the toolbar icon and clear the reference
self.axis_settings_dialog.finished.connect(self._axis_settings_closed)
self.axis_settings_dialog.show()
settings_action.setChecked(True)
else:
# If already open, bring it to the front
self.axis_settings_dialog.raise_()
self.axis_settings_dialog.activateWindow()
settings_action.setChecked(True) # keep it toggled
def _axis_settings_closed(self):
"""
Slot for when the axis settings dialog is closed.
"""
self.axis_settings_dialog = None
self.toolbar.widgets["axis"].action.setChecked(False)
################################################################################
# Toggle UI Elements
################################################################################
@property
def ui_mode(self) -> UIMode:
return self._ui_mode
@ui_mode.setter
def ui_mode(self, mode: UIMode):
if not isinstance(mode, UIMode):
raise ValueError("ui_mode must be an instance of UIMode")
self._ui_mode = mode
# First, clear both UI elements:
if self.popup_bundle is not None:
for action_id in self.toolbar.bundles["popup_bundle"]:
self.toolbar.widgets[action_id].action.setVisible(False)
if self.axis_settings_dialog is not None and self.axis_settings_dialog.isVisible():
self.axis_settings_dialog.close()
self.side_panel.hide()
# Now, apply the new mode:
if mode == UIMode.POPUP:
if self.popup_bundle is None:
self.add_popups()
else:
for action_id in self.toolbar.bundles["popup_bundle"]:
self.toolbar.widgets[action_id].action.setVisible(True)
elif mode == UIMode.SIDE:
self.add_side_menus()
self.side_panel.show()
@SafeProperty(bool, doc="Enable popups setting dialogs for the plot widget.")
def enable_popups(self):
return self.ui_mode == UIMode.POPUP
@enable_popups.setter
def enable_popups(self, value: bool):
if value:
self.ui_mode = UIMode.POPUP
else:
if self.ui_mode == UIMode.POPUP:
self.ui_mode = UIMode.NONE
@SafeProperty(bool, doc="Show Side Panel")
def enable_side_panel(self) -> bool:
return self.ui_mode == UIMode.SIDE
@enable_side_panel.setter
def enable_side_panel(self, value: bool):
if value:
self.ui_mode = UIMode.SIDE
else:
if self.ui_mode == UIMode.SIDE:
self.ui_mode = UIMode.NONE
@SafeProperty(bool, doc="Show Toolbar")
def enable_toolbar(self) -> bool:
@@ -165,15 +280,22 @@ class PlotBase(BECWidget, QWidget):
@enable_toolbar.setter
def enable_toolbar(self, value: bool):
self.toolbar.setVisible(value)
@SafeProperty(bool, doc="Show Side Panel")
def enable_side_panel(self) -> bool:
return self.side_panel.isVisible()
@enable_side_panel.setter
def enable_side_panel(self, value: bool):
self.side_panel.setVisible(value)
if value:
# Disable popup mode
if self._popups:
# Directly update the internal flag to avoid recursion
self._popups = False
# Hide the popup bundle if it exists and close any open dialogs
if self.popup_bundle is not None:
for action in self.toolbar.bundles["popup_bundle"].actions:
action.setVisible(False)
if self.axis_settings_dialog is not None and self.axis_settings_dialog.isVisible():
self.axis_settings_dialog.close()
self.side_panel.show()
# Add side menus if not already added
self.add_side_menus()
else:
self.side_panel.hide()
@SafeProperty(bool, doc="Enable the FPS monitor.")
def enable_fps_monitor(self) -> bool:
@@ -256,12 +378,45 @@ class PlotBase(BECWidget, QWidget):
@SafeProperty(str, doc="The text of the x label")
def x_label(self) -> str:
return self.plot_item.getAxis("bottom").labelText
return self._user_x_label
@x_label.setter
def x_label(self, value: str):
self.plot_item.setLabel("bottom", text=value)
self.property_changed.emit("x_label", value)
self._user_x_label = value
self._apply_x_label()
self.property_changed.emit("x_label", self._user_x_label)
@property
def x_label_suffix(self) -> str:
"""
A read-only (or internal) suffix automatically appended to the user label.
Not settable by the user directly from the UI.
"""
return self._x_label_suffix
def set_x_label_suffix(self, suffix: str):
"""
Public or protected method to update the suffix.
The user code or subclass (Waveform) can call this
when x_mode changes, but the AxisSettings won't show it.
"""
self._x_label_suffix = suffix
self._apply_x_label()
@property
def x_label_combined(self) -> str:
"""
The final label shown on the axis = user portion + suffix.
"""
return self._user_x_label + self._x_label_suffix
def _apply_x_label(self):
"""
Actually updates the pyqtgraph axis label text to
the combined label. Called whenever user label or suffix changes.
"""
final_label = self.x_label_combined
self.plot_item.setLabel("bottom", text=final_label)
@SafeProperty(str, doc="The text of the y label")
def y_label(self) -> str:
@@ -545,6 +700,7 @@ class PlotBase(BECWidget, QWidget):
self.unhook_crosshair()
self.unhook_fps_monitor(delete_label=True)
self.cleanup_pyqtgraph()
self.rpc_register.remove_rpc(self)
def cleanup_pyqtgraph(self):
"""Cleanup pyqtgraph items."""
@@ -555,17 +711,34 @@ class PlotBase(BECWidget, QWidget):
item.ctrlMenu.deleteLater()
class DemoPlotBase(QMainWindow): # pragma: no cover:
def __init__(self):
super().__init__()
self.main_widget = QWidget()
self.setCentralWidget(self.main_widget)
self.main_widget.layout = QHBoxLayout(self.main_widget)
self.plot_popup = PlotBase(popups=True)
self.plot_popup.title = "PlotBase with popups"
self.plot_side_panels = PlotBase(popups=False)
self.plot_side_panels.title = "PlotBase with side panels"
self.plot_popup.plot_item.plot(np.random.rand(100), pen=(255, 0, 0))
self.plot_side_panels.plot_item.plot(np.random.rand(100), pen=(0, 255, 0))
self.main_widget.layout.addWidget(self.plot_side_panels)
self.main_widget.layout.addWidget(self.plot_popup)
self.resize(1400, 600)
if __name__ == "__main__": # pragma: no cover:
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
set_theme("dark")
widget = PlotBase()
widget.show()
# Just some example data and parameters to test
widget.y_grid = True
widget.plot_item.plot([1, 2, 3, 4, 5], [1, 2, 3, 4, 5])
window = DemoPlotBase()
window.show()
sys.exit(app.exec_())

View File

@@ -9,7 +9,7 @@ from bec_widgets.utils.widget_io import WidgetIO
class AxisSettings(SettingWidget):
def __init__(self, parent=None, target_widget=None, *args, **kwargs):
def __init__(self, parent=None, target_widget=None, popup=False, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
# This is a settings widget that depends on the target widget
@@ -18,9 +18,15 @@ class AxisSettings(SettingWidget):
self.setProperty("skip_settings", True)
self.setObjectName("AxisSettings")
current_path = os.path.dirname(__file__)
form = UILoader().load_ui(os.path.join(current_path, "axis_settings_vertical.ui"), self)
if popup:
form = UILoader().load_ui(
os.path.join(current_path, "axis_settings_horizontal.ui"), self
)
else:
form = UILoader().load_ui(os.path.join(current_path, "axis_settings_vertical.ui"), self)
self.target_widget = target_widget
self.popup = popup
# # Scroll area
self.scroll_area = QScrollArea(self)
@@ -34,10 +40,13 @@ class AxisSettings(SettingWidget):
# self.layout.addWidget(self.ui)
self.ui = form
self.connect_all_signals()
if self.target_widget is not None:
if self.target_widget is not None and self.popup is False:
self.connect_all_signals()
self.target_widget.property_changed.connect(self.update_property)
if self.popup is True:
self.fetch_all_properties()
def connect_all_signals(self):
for widget in [
self.ui.title,
@@ -93,3 +102,49 @@ class AxisSettings(SettingWidget):
was_blocked = widget_to_set.blockSignals(True)
WidgetIO.set_value(widget_to_set, value)
widget_to_set.blockSignals(was_blocked)
def fetch_all_properties(self):
"""
Fetch all properties from the target widget and update the settings widget.
"""
for widget in [
self.ui.title,
self.ui.inner_axes,
self.ui.outer_axes,
self.ui.x_label,
self.ui.x_min,
self.ui.x_max,
self.ui.x_log,
self.ui.x_grid,
self.ui.y_label,
self.ui.y_min,
self.ui.y_max,
self.ui.y_log,
self.ui.y_grid,
]:
property_name = widget.objectName()
value = getattr(self.target_widget, property_name)
WidgetIO.set_value(widget, value)
def accept_changes(self):
"""
Apply all properties from the settings widget to the target widget.
"""
for widget in [
self.ui.title,
self.ui.inner_axes,
self.ui.outer_axes,
self.ui.x_label,
self.ui.x_min,
self.ui.x_max,
self.ui.x_log,
self.ui.x_grid,
self.ui.y_label,
self.ui.y_min,
self.ui.y_max,
self.ui.y_log,
self.ui.y_grid,
]:
property_name = widget.objectName()
value = WidgetIO.get_value(widget)
setattr(self.target_widget, property_name, value)

View File

@@ -6,97 +6,115 @@
<rect>
<x>0</x>
<y>0</y>
<width>427</width>
<height>270</height>
<width>486</width>
<height>300</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="2">
<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">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
<item row="1" column="2">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
<string>Y Axis</string>
<item row="1" column="3">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</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="0" colspan="2">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="5" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="y_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
<item row="3" column="2">
<widget class="ToggleSwitch" name="x_log">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="x_max_label">
<property name="text">
<string>Max</string>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</widget>
</item>
<item row="5" column="2">
<widget class="ToggleSwitch" name="x_grid">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
<string>Log</string>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="y_min_label">
<widget class="QLabel" name="x_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="x_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_label"/>
</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>
<widget class="BECSpinBox" name="x_min"/>
</item>
<item row="2" column="2">
<widget class="BECSpinBox" name="x_max"/>
</item>
</layout>
</widget>
</item>
<item row="2" column="2" colspan="2">
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
<string>Y Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="y_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
@@ -106,7 +124,7 @@
<item row="3" column="0">
<widget class="QLabel" name="y_scale_label">
<property name="text">
<string>Scale</string>
<string>Log</string>
</property>
</widget>
</item>
@@ -124,13 +142,6 @@
</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">
@@ -138,113 +149,58 @@
</property>
</widget>
</item>
</layout>
</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>
<widget class="ToggleSwitch" name="y_log">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QCheckBox" name="x_grid">
<property name="text">
<string/>
<widget class="ToggleSwitch" name="y_grid">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
<item row="1" column="2">
<widget class="BECSpinBox" name="y_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="buttonSymbols">
<enum>QAbstractSpinBox::ButtonSymbols::UpDownArrows</enum>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="BECSpinBox" name="y_max"/>
</item>
</layout>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="switch_outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
<item row="0" column="0" colspan="4">
<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="title"/>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>BECSpinBox</class>
<extends>QDoubleSpinBox</extends>
<header>bec_spin_box</header>
</customwidget>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>

View File

@@ -20,19 +20,6 @@
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<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="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
@@ -64,19 +51,6 @@
</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">
@@ -98,6 +72,12 @@
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="BECSpinBox" name="x_min"/>
</item>
<item row="2" column="2">
<widget class="BECSpinBox" name="x_max"/>
</item>
</layout>
</widget>
</item>
@@ -128,19 +108,6 @@
<string>Y Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<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">
@@ -148,19 +115,6 @@
</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>
@@ -206,6 +160,12 @@
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="BECSpinBox" name="y_min"/>
</item>
<item row="2" column="2">
<widget class="BECSpinBox" name="y_max"/>
</item>
</layout>
</widget>
</item>
@@ -229,6 +189,11 @@
</layout>
</widget>
<customwidgets>
<customwidget>
<class>BECSpinBox</class>
<extends>QDoubleSpinBox</extends>
<header>bec_spin_box</header>
</customwidget>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>

View File

@@ -1,7 +1,8 @@
import pyqtgraph as pg
from qtpy.QtCore import QTimer
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ToolbarBundle
from bec_widgets.qt_utils.toolbar import MaterialIconAction, SwitchableToolBarAction, ToolbarBundle
class MouseInteractionToolbarBundle(ToolbarBundle):
@@ -15,6 +16,7 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
self.mouse_mode = None
# Create each MaterialIconAction with a parent
# so the signals can fire even if the toolbar isn't added yet.
@@ -43,9 +45,16 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
parent=self.target_widget,
)
self.switch_mouse_action = SwitchableToolBarAction(
actions={"drag_mode": drag, "rectangle_mode": rect},
initial_action="drag_mode",
tooltip="Mouse Modes",
checkable=True,
parent=self,
)
# Add them to the bundle
self.add_action("drag_mode", drag)
self.add_action("rectangle_mode", rect)
self.add_action("switch_mouse", self.switch_mouse_action)
self.add_action("auto_range", auto)
self.add_action("aspect_ratio", aspect_ratio)
@@ -55,12 +64,34 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
auto.action.triggered.connect(self.autorange_plot)
aspect_ratio.action.toggled.connect(self.lock_aspect_ratio)
# Give some time to check the state
QTimer.singleShot(10, self.get_viewbox_mode)
def get_viewbox_mode(self):
"""
Returns the current interaction mode of a PyQtGraph ViewBox and sets the corresponding action.
"""
if self.target_widget:
viewbox = self.target_widget.plot_item.getViewBox()
if viewbox.getState()["mouseMode"] == 3:
self.switch_mouse_action.set_default_action("drag_mode")
self.switch_mouse_action.main_button.setChecked(True)
self.mouse_mode = "PanMode"
elif viewbox.getState()["mouseMode"] == 1:
self.switch_mouse_action.set_default_action("rectangle_mode")
self.switch_mouse_action.main_button.setChecked(True)
self.mouse_mode = "RectMode"
@SafeSlot(bool)
def enable_mouse_rectangle_mode(self, checked: bool):
"""
Enable the rectangle zoom mode on the plot widget.
"""
self.actions["drag_mode"].action.setChecked(not checked)
if self.mouse_mode == "RectMode":
self.switch_mouse_action.main_button.setChecked(True)
return
self.actions["switch_mouse"].actions["drag_mode"].action.setChecked(not checked)
if self.target_widget and checked:
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode)
@@ -69,7 +100,10 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
"""
Enable the pan mode on the plot widget.
"""
self.actions["rectangle_mode"].action.setChecked(not checked)
if self.mouse_mode == "PanMode":
self.switch_mouse_action.main_button.setChecked(True)
return
self.actions["switch_mouse"].actions["rectangle_mode"].action.setChecked(not checked)
if self.target_widget and checked:
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)

View File

@@ -1,7 +1,7 @@
from pyqtgraph.exporters import MatplotlibExporter
from bec_widgets.qt_utils.error_popups import SafeSlot, WarningPopupUtility
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ToolbarBundle
from bec_widgets.qt_utils.toolbar import MaterialIconAction, SwitchableToolBarAction, ToolbarBundle
class PlotExportBundle(ToolbarBundle):
@@ -25,9 +25,16 @@ class PlotExportBundle(ToolbarBundle):
icon_name="photo_library", tooltip="Open Matplotlib Dialog", parent=self.target_widget
)
switch_export_action = SwitchableToolBarAction(
actions={"save": save, "matplotlib": matplotlib},
initial_action="save",
tooltip="Switchable Action",
checkable=False,
parent=self,
)
# Add them to the bundle
self.add_action("save", save)
self.add_action("matplotlib", matplotlib)
self.add_action("export_switch", switch_export_action)
# Immediately connect signals
save.action.triggered.connect(self.export_dialog)

View File

@@ -0,0 +1,26 @@
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ToolbarBundle
class ROIBundle(ToolbarBundle):
"""
A bundle of actions that are hooked in this constructor itself,
so that you can immediately connect the signals and toggle states.
This bundle is for a toolbar that controls crosshair and ROI interaction.
"""
def __init__(self, bundle_id="roi", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# Create each MaterialIconAction with a parent
# so the signals can fire even if the toolbar isn't added yet.
crosshair = MaterialIconAction(
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
)
# Add them to the bundle
self.add_action("crosshair", crosshair)
# Immediately connect signals
crosshair.action.toggled.connect(self.target_widget.toggle_crosshair)

View File

@@ -24,8 +24,8 @@ class BECProgressBar(BECWidget, QWidget):
]
ICON_NAME = "page_control"
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
accent_colors = get_accent_colors()

View File

@@ -99,6 +99,7 @@ class Ring(BECConnector):
config: RingConfig | dict | None = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
):
if config is None:
config = RingConfig(widget_class=self.__class__.__name__)
@@ -107,7 +108,7 @@ class Ring(BECConnector):
if isinstance(config, dict):
config = RingConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
self.parent_progress_widget = parent_progress_widget
self.color = None

View File

@@ -101,6 +101,7 @@ class RingProgressBar(BECWidget, QWidget):
client=None,
gui_id: str | None = None,
num_bars: int | None = None,
**kwargs,
):
if config is None:
config = RingProgressBarConfig(widget_class=self.__class__.__name__)
@@ -109,7 +110,7 @@ class RingProgressBar(BECWidget, QWidget):
if isinstance(config, dict):
config = RingProgressBarConfig(**config, widget_class=self.__class__.__name__)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()

View File

@@ -42,8 +42,9 @@ class BECQueue(BECWidget, CompactPopupWidget):
config: ConnectionConfig = None,
gui_id: str = None,
refresh_upon_start: bool = True,
**kwargs,
):
super().__init__(client, config, gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)

View File

@@ -87,8 +87,9 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
client: BECClient = None,
bec_service_status_mixin: BECServiceStatusMixin = None,
gui_id: str = None,
**kwargs,
):
super().__init__(client=client, gui_id=gui_id)
super().__init__(client=client, gui_id=gui_id, **kwargs)
CompactPopupWidget.__init__(self, parent=parent, layout=QHBoxLayout)
self.box_name = box_name

View File

@@ -23,8 +23,9 @@ class DeviceBrowser(BECWidget, QWidget):
config=None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
) -> None:
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
self.get_bec_shortcuts()

View File

@@ -0,0 +1,3 @@
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
__ALL__ = ["LogPanel"]

View File

@@ -0,0 +1,58 @@
""" Utilities for filtering and formatting in the LogPanel"""
from __future__ import annotations
import re
from collections import deque
from typing import Callable, Iterator
from bec_lib.logger import LogLevel
from bec_lib.messages import LogMessage
from qtpy.QtCore import QDateTime
LinesHtmlFormatter = Callable[[deque[LogMessage]], Iterator[str]]
LineFormatter = Callable[[LogMessage], str]
LineFilter = Callable[[LogMessage], bool] | None
ANSI_ESCAPE_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def replace_escapes(s: str):
s = ANSI_ESCAPE_REGEX.sub("", s)
return s.replace(" ", "&nbsp;").replace("\n", "<br />").replace("\t", " ")
def level_filter(msg: LogMessage, thresh: int):
return LogLevel[msg.content["log_type"].upper()].value >= thresh
def noop_format(line: LogMessage):
_textline = line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
return replace_escapes(_textline.strip()) + "<br />"
def simple_color_format(line: LogMessage, colors: dict[LogLevel, str]):
color = colors.get(LogLevel[line.content["log_type"].upper()]) or colors[LogLevel.INFO]
return f'<font color="{color}">{noop_format(line)}</font>'
def create_formatter(line_format: LineFormatter, line_filter: LineFilter) -> LinesHtmlFormatter:
def _formatter(data: deque[LogMessage]):
if line_filter is not None:
return (line_format(line) for line in data if line_filter(line))
else:
return (line_format(line) for line in data)
return _formatter
def log_txt(line):
return line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
def log_time(line):
return QDateTime.fromMSecsSinceEpoch(int(line.log_msg["record"]["time"]["timestamp"] * 1000))
def log_svc(line):
return line.log_msg["service_name"]

View File

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

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
DOM_XML = """
<ui language='c++'>
<widget class='LogPanel' name='log_panel'>
</widget>
</ui>
"""
class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = LogPanel(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Utils"
def icon(self):
return designer_material_icon(LogPanel.ICON_NAME)
def includeFile(self):
return "log_panel"
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 "LogPanel"
def toolTip(self):
return "Displays a log panel"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,529 @@
""" Module for a LogPanel widget to display BEC log messages """
from __future__ import annotations
import operator
import os
import re
from collections import deque
from functools import partial, reduce
from re import Pattern
from typing import TYPE_CHECKING, Literal
from bec_lib.client import BECClient
from bec_lib.connector import ConnectorBase
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import LogLevel, bec_logger
from bec_lib.messages import LogMessage, StatusMessage
from qtpy.QtCore import QDateTime, Qt, Signal # type: ignore
from qtpy.QtGui import QFont
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QDateTimeEdit,
QDialog,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QTextEdit,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.colors import get_theme_palette, set_theme
from bec_widgets.widgets.editors.text_box.text_box import TextBox
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECServiceStatusMixin
from bec_widgets.widgets.utility.logpanel._util import (
LineFilter,
LineFormatter,
LinesHtmlFormatter,
create_formatter,
level_filter,
log_svc,
log_time,
log_txt,
noop_format,
simple_color_format,
)
if TYPE_CHECKING:
from PySide6.QtCore import SignalInstance
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
# TODO: improve log color handling
DEFAULT_LOG_COLORS = {
LogLevel.INFO: "#FFFFFF",
LogLevel.SUCCESS: "#00FF00",
LogLevel.WARNING: "#FFCC00",
LogLevel.ERROR: "#FF0000",
LogLevel.DEBUG: "#0000CC",
}
class BecLogsQueue:
"""Manages getting logs from BEC Redis and formatting them for display"""
def __init__(
self,
conn: ConnectorBase,
new_message_signal: SignalInstance,
maxlen: int = 1000,
line_formatter: LineFormatter = noop_format,
) -> None:
self._timestamp_start: QDateTime | None = None
self._timestamp_end: QDateTime | None = None
self._conn = conn
self._new_message_signal: SignalInstance | None = new_message_signal
self._max_length = maxlen
self._data: deque[LogMessage] = deque([], self._max_length)
self._display_queue: deque[str] = deque([], self._max_length)
self._log_level: str | None = None
self._search_query: Pattern | str | None = None
self._selected_services: set[str] | None = None
self._set_formatter_and_update_filter(line_formatter)
self._conn.register([MessageEndpoints.log()], None, self._process_incoming_log_msg)
def disconnect(self):
self._conn.unregister([MessageEndpoints.log()], None, self._process_incoming_log_msg)
self._new_message_signal.disconnect()
def _process_incoming_log_msg(self, msg: dict):
try:
_msg: LogMessage = msg["data"]
self._data.append(_msg)
if self.filter is None or self.filter(_msg):
self._display_queue.append(self._line_formatter(_msg))
if self._new_message_signal:
self._new_message_signal.emit()
except Exception:
logger.warning("Error in LogPanel incoming message callback!")
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
self._line_formatter: LineFormatter = line_formatter
self._queue_formatter: LinesHtmlFormatter = create_formatter(
self._line_formatter, self.filter
)
def _combine_filters(self, *args: LineFilter):
return lambda msg: reduce(operator.and_, [filt(msg) for filt in args if filt is not None])
def _create_re_filter(self) -> LineFilter:
if self._search_query is None:
return None
elif isinstance(self._search_query, str):
return lambda line: self._search_query in log_txt(line)
return lambda line: self._search_query.match(log_txt(line)) is not None
def _create_service_filter(self):
return (
lambda line: self._selected_services is None or log_svc(line) in self._selected_services
)
def _create_timestamp_filter(self) -> LineFilter:
s, e = self._timestamp_start, self._timestamp_end
if s is e is None:
return lambda msg: True
def _time_filter(msg):
msg_time = log_time(msg)
if s is None:
return msg_time <= e
if e is None:
return s <= msg_time
return s <= msg_time <= e
return _time_filter
@property
def filter(self) -> LineFilter:
thresh = LogLevel[self._log_level].value if self._log_level is not None else 0
return self._combine_filters(
partial(level_filter, thresh=thresh),
self._create_re_filter(),
self._create_timestamp_filter(),
self._create_service_filter(),
)
def update_level_filter(self, level: str):
if level not in [l.name for l in LogLevel]:
logger.error(f"Logging level {level} unrecognized for filter!")
return
self._log_level = level
self._set_formatter_and_update_filter(self._line_formatter)
def update_search_filter(self, search_query: Pattern | str | None = None):
self._search_query = search_query
self._set_formatter_and_update_filter(self._line_formatter)
def update_time_filter(self, start: QDateTime | None, end: QDateTime | None):
self._timestamp_start = start
self._timestamp_end = end
self._set_formatter_and_update_filter(self._line_formatter)
def update_service_filter(self, services: set[str]):
self._selected_services = services
self._set_formatter_and_update_filter(self._line_formatter)
def update_line_formatter(self, line_formatter: LineFormatter):
self._set_formatter_and_update_filter(line_formatter)
def display_all(self) -> str:
return "\n".join(self._queue_formatter(self._data.copy()))
def format_new(self):
res = "\n".join(self._display_queue)
self._display_queue = deque([], self._max_length)
return res
def clear_logs(self):
self._data = deque([])
self._display_queue = deque([])
def fetch_history(self):
self._data = deque(
item["data"]
for item in self._conn.xread(
MessageEndpoints.log().endpoint, from_start=True, count=self._max_length
)
)
def unique_service_names_from_history(self) -> set[str]:
return set(msg.log_msg["service_name"] for msg in self._data)
class LogPanelToolbar(QWidget):
services_selected: pyqtBoundSignal = Signal(set)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
# in unix time
self._timestamp_start: QDateTime | None = None
self._timestamp_end: QDateTime | None = None
self._unique_service_names: set[str] = set()
self._services_selected: set[str] | None = None
self.layout = QHBoxLayout(self) # type: ignore
self.service_choice_button = QPushButton("Select services", self)
self.layout.addWidget(self.service_choice_button)
self.service_choice_button.clicked.connect(self._open_service_filter_dialog)
self.filter_level_dropdown = self._log_level_box()
self.layout.addWidget(self.filter_level_dropdown)
self.clear_button = QPushButton("Clear all", self)
self.layout.addWidget(self.clear_button)
self.fetch_button = QPushButton("Fetch history", self)
self.layout.addWidget(self.fetch_button)
self._string_search_box()
self.timerange_button = QPushButton("Set time range", self)
self.layout.addWidget(self.timerange_button)
@property
def time_start(self):
return self._timestamp_start
@property
def time_end(self):
return self._timestamp_end
def _string_search_box(self):
self.layout.addWidget(QLabel("Search: "))
self.search_textbox = QLineEdit()
self.layout.addWidget(self.search_textbox)
self.layout.addWidget(QLabel("Use regex: "))
self.regex_enabled = QCheckBox()
self.layout.addWidget(self.regex_enabled)
self.update_re_button = QPushButton("Update search", self)
self.layout.addWidget(self.update_re_button)
def _log_level_box(self):
box = QComboBox()
box.setToolTip("Display logs with equal or greater significance to the selected level.")
[box.addItem(l.name) for l in LogLevel]
return box
def _current_ts(self, selection_type: Literal["start", "end"]):
if selection_type == "start":
return self._timestamp_start
elif selection_type == "end":
return self._timestamp_end
else:
raise ValueError(f"timestamps can only be for the start or end, not {selection_type}")
def _open_datetime_dialog(self):
"""Open dialog window for timestamp filter selection"""
self._dt_dialog = QDialog(self)
self._dt_dialog.setWindowTitle("Time range selection")
layout = QVBoxLayout()
self._dt_dialog.setLayout(layout)
label_start = QLabel(parent=self._dt_dialog)
label_end = QLabel(parent=self._dt_dialog)
def date_button_set(selection_type: Literal["start", "end"], label: QLabel):
dt = self._current_ts(selection_type)
_layout = QHBoxLayout()
layout.addLayout(_layout)
date_button = QPushButton(f"Time {selection_type}", parent=self._dt_dialog)
_layout.addWidget(date_button)
label.setText(dt.toString() if dt else "not selected")
_layout.addWidget(label)
date_button.clicked.connect(partial(self._open_cal_dialog, selection_type, label))
date_clear_button = QPushButton("clear", parent=self._dt_dialog)
date_clear_button.clicked.connect(
lambda: (
partial(self._update_time, selection_type)(None),
label.setText("not selected"),
)
)
_layout.addWidget(date_clear_button)
for v in [("start", label_start), ("end", label_end)]:
date_button_set(*v)
close_button = QPushButton("Close", parent=self._dt_dialog)
close_button.clicked.connect(self._dt_dialog.accept)
layout.addWidget(close_button)
self._dt_dialog.exec()
self._dt_dialog.deleteLater()
def _open_cal_dialog(self, selection_type: Literal["start", "end"], label: QLabel):
"""Open dialog window for timestamp filter selection"""
dt = self._current_ts(selection_type) or QDateTime.currentDateTime()
label.setText(dt.toString() if dt else "not selected")
if selection_type == "start":
self._timestamp_start = dt
else:
self._timestamp_end = dt
self._cal_dialog = QDialog(self)
self._cal_dialog.setWindowTitle(f"Select time range {selection_type}")
layout = QVBoxLayout()
self._cal_dialog.setLayout(layout)
cal = QDateTimeEdit(parent=self._cal_dialog)
cal.setCalendarPopup(True)
cal.setDateTime(dt)
cal.setDisplayFormat("yyyy-MM-dd HH:mm:ss.zzz")
cal.dateTimeChanged.connect(partial(self._update_time, selection_type))
layout.addWidget(cal)
close_button = QPushButton("Close", parent=self._cal_dialog)
close_button.clicked.connect(self._cal_dialog.accept)
layout.addWidget(close_button)
self._cal_dialog.exec()
self._cal_dialog.deleteLater()
def _update_time(self, selection_type: Literal["start", "end"], dt: QDateTime | None):
if selection_type == "start":
self._timestamp_start = dt
else:
self._timestamp_end = dt
@SafeSlot(dict, set)
def service_list_update(
self, services_info: dict[str, StatusMessage], services_from_history: set[str], *_, **__
):
self._unique_service_names = set([s.split("/")[0] for s in services_info.keys()])
self._unique_service_names |= services_from_history
if self._services_selected is None:
self._services_selected = self._unique_service_names
@SafeSlot()
def _open_service_filter_dialog(self):
if len(self._unique_service_names) == 0 or self._services_selected is None:
return
self._svc_dialog = QDialog(self)
self._svc_dialog.setWindowTitle(f"Select services to show logs from")
layout = QVBoxLayout()
self._svc_dialog.setLayout(layout)
service_cb_grid = QGridLayout(parent=self._svc_dialog)
layout.addLayout(service_cb_grid)
def check_box(name: str, checked: Qt.CheckState):
if checked == Qt.CheckState.Checked:
self._services_selected.add(name)
else:
if name in self._services_selected:
self._services_selected.remove(name)
self.services_selected.emit(self._services_selected)
for i, svc in enumerate(self._unique_service_names):
service_cb_grid.addWidget(QLabel(svc, parent=self._svc_dialog), i, 0)
cb = QCheckBox(parent=self._svc_dialog)
cb.setChecked(svc in self._services_selected)
cb.checkStateChanged.connect(partial(check_box, svc))
service_cb_grid.addWidget(cb, i, 1)
close_button = QPushButton("Close", parent=self._svc_dialog)
close_button.clicked.connect(self._svc_dialog.accept)
layout.addWidget(close_button)
self._svc_dialog.exec()
self._svc_dialog.deleteLater()
class LogPanel(TextBox):
"""Displays a log panel"""
ICON_NAME = "terminal"
_new_messages = Signal()
service_list_update = Signal(dict, set)
def __init__(
self,
parent=None,
client: BECClient | None = None,
service_status: BECServiceStatusMixin | None = None,
**kwargs,
):
"""Initialize the LogPanel widget."""
super().__init__(parent=parent, client=client, **kwargs)
self._update_colors()
self._service_status = service_status or BECServiceStatusMixin(self, client=self.client) # type: ignore
self._log_manager = BecLogsQueue(
self.client.connector, # type: ignore
new_message_signal=self._new_messages,
line_formatter=partial(simple_color_format, colors=self._colors),
)
self.toolbar = LogPanelToolbar(parent=parent)
self.toolbar_area = QScrollArea()
self.toolbar_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.toolbar_area.setSizeAdjustPolicy(QScrollArea.SizeAdjustPolicy.AdjustToContents)
self.toolbar_area.setFixedHeight(int(self.toolbar.clear_button.height() * 2))
self.toolbar_area.setWidget(self.toolbar)
self.layout.addWidget(self.toolbar_area)
self.toolbar.clear_button.clicked.connect(self._on_clear)
self.toolbar.fetch_button.clicked.connect(self._on_fetch)
self.toolbar.update_re_button.clicked.connect(self._on_re_update)
self.toolbar.search_textbox.returnPressed.connect(self._on_re_update)
self.toolbar.regex_enabled.checkStateChanged.connect(self._on_re_update)
self.toolbar.filter_level_dropdown.currentTextChanged.connect(self._set_level_filter)
self._new_messages.connect(self._on_append)
self.toolbar.timerange_button.clicked.connect(self._choose_datetime)
self._service_status.services_update.connect(self._update_service_list)
self.service_list_update.connect(self.toolbar.service_list_update)
self.toolbar.services_selected.connect(self._update_service_filter)
self.text_box_text_edit.setFont(QFont("monospace", 12))
self.text_box_text_edit.setHtml("")
self.text_box_text_edit.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
self._connect_to_theme_change()
@SafeSlot(set)
def _update_service_filter(self, services: set[str]):
self._log_manager.update_service_filter(services)
self._on_redraw()
@SafeSlot(dict, dict)
def _update_service_list(self, services_info: dict[str, StatusMessage], *_, **__):
self.service_list_update.emit(
services_info, self._log_manager.unique_service_names_from_history()
)
@SafeSlot()
def _choose_datetime(self):
self.toolbar._open_datetime_dialog()
self._set_time_filter()
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._on_redraw) # type: ignore
def _update_colors(self):
self._colors = DEFAULT_LOG_COLORS.copy()
self._colors.update({LogLevel.INFO: get_theme_palette().text().color().name()})
def _cursor_to_end(self):
c = self.text_box_text_edit.textCursor()
c.movePosition(c.MoveOperation.End)
self.text_box_text_edit.setTextCursor(c)
@SafeSlot()
@SafeSlot(str)
def _on_redraw(self, *_):
self._update_colors()
self._log_manager.update_line_formatter(partial(simple_color_format, colors=self._colors))
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot()
def _on_append(self):
self._cursor_to_end()
self.text_box_text_edit.insertHtml(self._log_manager.format_new())
@SafeSlot()
def _on_clear(self):
self._log_manager.clear_logs()
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot()
@SafeSlot(Qt.CheckState)
def _on_re_update(self, *_):
if self.toolbar.regex_enabled.isChecked():
try:
search_query = re.compile(self.toolbar.search_textbox.text())
except Exception as e:
logger.warning(f"Failed to compile search regex with error {e}")
search_query = None
logger.info(f"Setting LogPanel search regex to {search_query}")
else:
search_query = self.toolbar.search_textbox.text()
logger.info(f'Setting LogPanel search string to "{search_query}"')
self._log_manager.update_search_filter(search_query)
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot()
def _on_fetch(self):
self._log_manager.fetch_history()
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot(str)
def _set_level_filter(self, level: str):
self._log_manager.update_level_filter(level)
self._on_redraw()
@SafeSlot()
def _set_time_filter(self):
self._log_manager.update_time_filter(self.toolbar.time_start, self.toolbar.time_end)
self._on_redraw()
def cleanup(self):
self._service_status.cleanup()
self._log_manager.disconnect()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
app = QApplication(sys.argv)
set_theme("dark")
widget = LogPanel()
widget.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,15 @@
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.utility.logpanel.log_panel_plugin import LogPanelPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(LogPanelPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

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

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
DOM_XML = """
<ui language='c++'>
<widget class='BECSpinBox' name='bec_spin_box'>
</widget>
</ui>
"""
class BECSpinBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECSpinBox(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon(BECSpinBox.ICON_NAME)
def includeFile(self):
return "bec_spin_box"
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 "BECSpinBox"
def toolTip(self):
return "BECSpinBox"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,84 @@
import sys
from bec_qthemes import material_icon
from qtpy.QtGui import Qt
from qtpy.QtWidgets import (
QApplication,
QDoubleSpinBox,
QInputDialog,
QSizePolicy,
QToolButton,
QWidget,
)
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
class BECSpinBox(BECWidget, QDoubleSpinBox):
PLUGIN = True
RPC = False
ICON_NAME = "123"
def __init__(
self,
parent: QWidget | None = None,
config: ConnectionConfig | None = None,
client=None,
gui_id: str | None = None,
**kwargs,
) -> None:
if config is None:
config = ConnectionConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
QDoubleSpinBox.__init__(self, parent=parent)
self.setObjectName("BECSpinBox")
# Make the widget as compact as possible horizontally.
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
self.setAlignment(Qt.AlignHCenter)
# Configure default QDoubleSpinBox settings.
self.setRange(-2147483647, 2147483647)
self.setDecimals(2)
# Create an embedded settings button.
self.setting_button = QToolButton(self)
self.setting_button.setIcon(material_icon("settings"))
self.setting_button.setToolTip("Set number of decimals")
self.setting_button.setCursor(Qt.PointingHandCursor)
self.setting_button.setFocusPolicy(Qt.NoFocus)
self.setting_button.setStyleSheet("QToolButton { border: none; padding: 0px; }")
self.setting_button.clicked.connect(self.change_decimals)
self._button_size = 12
self._arrow_width = 20
def resizeEvent(self, event):
super().resizeEvent(event)
arrow_width = self._arrow_width
# Position the settings button inside the spin box, to the left of the arrow buttons.
x = self.width() - arrow_width - self._button_size - 2 # 2px margin
y = (self.height() - self._button_size) // 2
self.setting_button.setFixedSize(self._button_size, self._button_size)
self.setting_button.move(x, y)
def change_decimals(self):
"""
Change the number of decimals in the spin box.
"""
current = self.decimals()
new_decimals, ok = QInputDialog.getInt(
self, "Set Decimals", "Number of decimals:", current, 0, 10, 1
)
if ok:
self.setDecimals(new_decimals)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = BECSpinBox()
window.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,15 @@
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.utility.spinbox.bec_spin_box_plugin import BECSpinBoxPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECSpinBoxPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -12,8 +12,8 @@ class BECColorMapWidget(BECWidget, QWidget):
USER_ACCESS = ["colormap"]
PLUGIN = True
def __init__(self, parent=None, cmap: str = "magma"):
super().__init__()
def __init__(self, parent=None, cmap: str = "magma", **kwargs):
super().__init__(**kwargs)
QWidget.__init__(self, parent=parent)
# Create the ColorMapButton

View File

@@ -20,8 +20,9 @@ class DarkModeButton(BECWidget, QWidget):
client=None,
gui_id: str | None = None,
toolbar: bool = False,
**kwargs,
) -> None:
super().__init__(client=client, gui_id=gui_id, theme_update=True)
super().__init__(client=client, gui_id=gui_id, theme_update=True, **kwargs)
QWidget.__init__(self, parent)
self._dark_mode_enabled = False

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "1.19.2"
version = "1.25.0"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -15,14 +15,16 @@ classifiers = [
dependencies = [
"bec_ipython_client>=2.21.4, <=4.0", # needed for jupyter console
"bec_lib>=2.21.4, <=4.0",
"bec_qthemes~=0.7, >=0.7",
"black~=24.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"pydantic~=2.0",
"pyqtgraph~=0.13",
"bec_qthemes~=0.7, >=0.7",
"PySide6>=6.8",
"pyte", # needed for vt100 console
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
"pyte", # needed for vt100 console
"cryptography~=44.0",
]
@@ -37,7 +39,6 @@ dev = [
"pytest-xvfb~=3.0",
"pytest~=8.0",
]
pyside6 = ["PySide6==6.7.2"]
[project.urls]
"Bug Tracker" = "https://gitlab.psi.ch/bec/bec_widgets/issues"

View File

@@ -372,6 +372,7 @@ def test_rpc_call_with_exception_in_safeslot_error_popup(connected_client_gui_ob
gui.main.add_dock("test")
qtbot.waitUntil(lambda: len(gui.main.panels) == 2) # default_figure + test
qtbot.wait(500)
with pytest.raises(ValueError):
gui.main.add_dock("test")
# time.sleep(0.1)

View File

@@ -29,6 +29,8 @@ def test_axis_settings_init(axis_settings_fixture):
assert axis_settings.layout.count() == 1 # scroll area
# Check the target
assert axis_settings.target_widget == plot_base
# Check the object name
assert axis_settings.objectName() == "AxisSettings"
def test_change_ui_updates_plot_base(axis_settings_fixture, qtbot):
@@ -103,3 +105,76 @@ def test_scroll_area_behavior(axis_settings_fixture, qtbot):
axis_settings, plot_base = axis_settings_fixture
scroll_area = axis_settings.scroll_area
assert scroll_area.widgetResizable() is True
def test_fetch_all_properties(axis_settings_fixture, qtbot):
"""
Tests the `fetch_all_properties` method ensuring that all the properties set on
the `plot_base` instance are correctly synchronized with the user interface (UI)
elements of the `axis_settings` instance.
"""
axis_settings, plot_base = axis_settings_fixture
# Set all properties on plot_base
plot_base.title = "Plot Title from Code"
plot_base.x_min = 0
plot_base.x_max = 100
plot_base.x_label = "X Label"
plot_base.x_log = True
plot_base.x_grid = True
plot_base.y_min = -50
plot_base.y_max = 50
plot_base.y_label = "Y Label"
plot_base.y_log = False
plot_base.y_grid = False
plot_base.outer_axes = True
# Fetch properties into the UI
axis_settings.fetch_all_properties()
# Verify all properties were correctly fetched
assert axis_settings.ui.title.text() == "Plot Title from Code"
# X axis properties
assert axis_settings.ui.x_min.value() == 0
assert axis_settings.ui.x_max.value() == 100
assert axis_settings.ui.x_label.text() == "X Label"
assert axis_settings.ui.x_log.checked is True
assert axis_settings.ui.x_grid.checked is True
# Y axis properties
assert axis_settings.ui.y_min.value() == -50
assert axis_settings.ui.y_max.value() == 50
assert axis_settings.ui.y_label.text() == "Y Label"
assert axis_settings.ui.y_log.checked is False
assert axis_settings.ui.y_grid.checked is False
# Other properties
assert axis_settings.ui.outer_axes.checked is True
def test_accept_changes(axis_settings_fixture, qtbot):
"""
Tests the functionality of applying user-defined changes to the axis settings
UI and verifying the reflected changes in the plot object's properties.
"""
axis_settings, plot_base = axis_settings_fixture
axis_settings.ui.title.setText("New Title")
axis_settings.ui.x_max.setValue(20)
axis_settings.ui.x_min.setValue(10)
axis_settings.ui.x_label.setText("New X Label")
axis_settings.ui.x_log.checked = True
axis_settings.ui.x_grid.checked = True
axis_settings.accept_changes()
qtbot.wait(200)
assert plot_base.title == "New Title"
assert plot_base.x_min == 10
assert plot_base.x_max == 20
assert plot_base.x_label == "New X Label"
assert plot_base.x_log is True
assert plot_base.x_grid is True

View File

@@ -4,7 +4,6 @@ import time
from unittest import mock
import pytest
import redis
from bec_lib.messages import ScanMessage
from bec_lib.serialization import MsgpackSerialization
@@ -21,13 +20,13 @@ def bec_dispatcher_w_connector(bec_dispatcher, topics_msg_list, send_msg_event):
time.sleep(0.2)
yield StopIteration
with mock.patch("redis.Redis"):
pubsub = redis.Redis().pubsub()
messages = pubsub_msg_generator()
pubsub.get_message.side_effect = lambda timeout: next(messages)
connector = QtRedisConnector("localhost:1")
bec_dispatcher.client.connector = connector
yield bec_dispatcher
redis_class_mock = mock.MagicMock()
pubsub = redis_class_mock().pubsub()
messages = pubsub_msg_generator()
pubsub.get_message.side_effect = lambda timeout: next(messages)
connector = QtRedisConnector("localhost:1", redis_class_mock)
bec_dispatcher.client.connector = connector
yield bec_dispatcher
dummy_msg = MsgpackSerialization.dumps(ScanMessage(point_id=0, scan_id="0", data={}))

View File

@@ -81,5 +81,10 @@ def test_client_utils_passes_client_config_to_server(bec_dispatcher):
wait=False
) # the started event will not be set, wait=True would block forever
mock_start_plot.assert_called_once_with(
"gui_id", BECGuiClient, mixin._client._service_config.config, logger=mock.ANY
"gui_id",
BECGuiClient,
mixin._client._service_config.config,
logger=mock.ANY,
token=mock.ANY,
acl_data=mock.ANY,
)

View File

@@ -1,9 +1,15 @@
import pyqtgraph as pg
import pytest
from pydantic import ValidationError
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils import Colors
from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.figure.plots.waveform.waveform_curve import CurveConfig
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
def test_color_validation_CSS():
@@ -110,3 +116,55 @@ def test_golder_angle_colors(num):
assert all(color.isValid() for color in colors_qcolor)
assert all(color.startswith("#") for color in colors_hex)
##################################################
# Testing of the ExamplePlotWidget theme change
##################################################
class ExamplePlotWidget(BECWidget, QWidget):
def __init__(
self,
parent: QWidget | None = None,
config: ConnectionConfig | None = None,
client=None,
gui_id: str | None = None,
) -> None:
if config is None:
config = ConnectionConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, gui_id=gui_id, config=config)
QWidget.__init__(self, parent=parent)
self.layout = QVBoxLayout(self)
self.glw = pg.GraphicsLayoutWidget()
self.pi = pg.PlotItem()
self.layout.addWidget(self.glw)
self.glw.addItem(self.pi)
self.pi.plot([1, 2, 3, 4, 5], pen="r")
def test_apply_theme(qtbot, mocked_client):
widget = create_widget(qtbot, ExamplePlotWidget, client=mocked_client)
apply_theme("dark")
# Get the default state of dark theme
dark_bg = widget.glw.backgroundBrush().color().name()
dark_axis_color = widget.pi.getAxis("left").pen().color().name()
dark_label_color = widget.pi.getAxis("left").textPen().color().name()
assert dark_bg == "#141414"
assert dark_axis_color == "#cccccc"
assert dark_label_color == "#ffffff"
apply_theme("light")
# Get the default state of light theme
light_bg = widget.glw.backgroundBrush().color().name()
light_axis_color = widget.pi.getAxis("left").pen().color().name()
light_label_color = widget.pi.getAxis("left").textPen().color().name()
assert light_bg == "#e9ecef"
assert light_axis_color == "#666666"
assert light_label_color == "#000000"

View File

@@ -0,0 +1,68 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import pytest
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QInputDialog
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
@pytest.fixture
def spinbox_fixture(qtbot):
widget = BECSpinBox()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_spinbox_initial_values(spinbox_fixture):
"""
Test the default properties of the BECSpinBox.
"""
spinbox = spinbox_fixture
assert spinbox.decimals() == 2
assert spinbox.minimum() == -2147483647
assert spinbox.maximum() == 2147483647
assert spinbox.setting_button is not None
def test_change_decimals_ui(spinbox_fixture, monkeypatch, qtbot):
"""
Test that clicking on the setting button triggers the QInputDialog to change decimals.
We'll simulate a user entering a new decimals value in the dialog.
"""
spinbox = spinbox_fixture
def mock_get_int(*args, **kwargs):
return (5, True)
monkeypatch.setattr(QInputDialog, "getInt", mock_get_int)
assert spinbox.decimals() == 2
qtbot.mouseClick(spinbox.setting_button, Qt.LeftButton)
assert spinbox.decimals() == 5
def test_change_decimals_cancel(spinbox_fixture, monkeypatch, qtbot):
"""
Test that if the user cancels the decimals dialog, the decimals do not change.
"""
spinbox = spinbox_fixture
def mock_get_int(*args, **kwargs):
return (0, False)
monkeypatch.setattr(QInputDialog, "getInt", mock_get_int)
old_decimals = spinbox.decimals()
qtbot.mouseClick(spinbox.setting_button, Qt.LeftButton)
assert spinbox.decimals() == old_decimals
def test_spinbox_value_change(spinbox_fixture):
"""
Test that the spinbox accepts user input and updates its value accordingly.
"""
spinbox = spinbox_fixture
assert spinbox.value() == 0.0
spinbox.setValue(123.456)
assert spinbox.value() == 123.46

View File

@@ -0,0 +1,133 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from collections import deque
from unittest.mock import MagicMock
import pytest
from bec_lib.messages import LogMessage
from qtpy.QtCore import QDateTime, Qt, Signal # type: ignore
from bec_widgets.widgets.utility.logpanel._util import (
log_time,
replace_escapes,
simple_color_format,
)
from bec_widgets.widgets.utility.logpanel.logpanel import DEFAULT_LOG_COLORS, LogPanel
from .client_mocks import mocked_client
TEST_TABLE_STRING = "2025-01-15 15:57:18 | bec_server.scan_server.scan_queue | [INFO] | \n \x1b[3m primary queue / ScanQueueStatus.RUNNING \x1b[0m\n┏━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┓\n\x1b[1m \x1b[0m\x1b[1m queue_id \x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_id\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mis_scan\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mtype\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_numb…\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mIQ status\x1b[0m\x1b[1m \x1b[0m┃\n┡━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━┩\n│ bbe50c82-6… │ None │ False │ mv │ None │ PENDING │\n└─────────────┴─────────┴─────────┴──────┴────────────┴───────────┘\n\n"
TEST_LOG_MESSAGES = [
LogMessage(
metadata={},
log_type="debug",
log_msg={
"text": "datetime | debug | test log message",
"record": {"time": {"timestamp": 123456789.000}},
"service_name": "ScanServer",
},
),
LogMessage(
metadata={},
log_type="info",
log_msg={
"text": "datetime | info | test log message",
"record": {"time": {"timestamp": 123456789.007}},
"service_name": "ScanServer",
},
),
LogMessage(
metadata={},
log_type="success",
log_msg={
"text": "datetime | success | test log message",
"record": {"time": {"timestamp": 123456789.012}},
"service_name": "ScanServer",
},
),
]
TEST_COMBINED_PLAINTEXT = "datetime | debug | test log message\ndatetime | info | test log message\ndatetime | success | test log message\n"
@pytest.fixture
def raw_queue():
yield deque(TEST_LOG_MESSAGES, maxlen=100)
@pytest.fixture
def log_panel(qtbot, mocked_client: MagicMock):
widget = LogPanel(client=mocked_client, service_status=MagicMock())
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_log_panel_init(log_panel: LogPanel):
assert log_panel.plain_text == ""
def test_table_string_processing():
assert "\x1b" in TEST_TABLE_STRING
sanitized = replace_escapes(TEST_TABLE_STRING)
assert "\x1b" not in sanitized
assert " " not in sanitized
assert "\n" not in sanitized
@pytest.mark.parametrize(
["msg", "color"], zip(TEST_LOG_MESSAGES, ["#0000CC", "#FFFFFF", "#00FF00"])
)
def test_color_format(msg: LogMessage, color: str):
assert color in simple_color_format(msg, DEFAULT_LOG_COLORS)
def test_logpanel_output(qtbot, log_panel: LogPanel):
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
log_panel._on_redraw()
assert log_panel.plain_text == TEST_COMBINED_PLAINTEXT
def display_queue_empty():
return len(log_panel._log_manager._display_queue) == 0
next_text = "datetime | error | test log message"
log_panel._log_manager._process_incoming_log_msg(
{
"data": LogMessage(
metadata={},
log_type="error",
log_msg={"text": next_text, "record": {}, "service_name": "ScanServer"},
)
}
)
qtbot.waitUntil(display_queue_empty)
assert log_panel.plain_text == TEST_COMBINED_PLAINTEXT + next_text + "\n"
def test_level_filter(log_panel: LogPanel):
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
log_panel._log_manager.update_level_filter("INFO")
log_panel._on_redraw()
assert (
log_panel.plain_text
== "datetime | info | test log message\ndatetime | success | test log message\n"
)
def test_clear_button(log_panel: LogPanel):
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
log_panel.toolbar.clear_button.click()
assert log_panel._log_manager._data == deque([])
def test_timestamp_filter(log_panel: LogPanel):
log_panel._log_manager._timestamp_start = QDateTime(1973, 11, 29, 21, 33, 9, 5, 1)
pytest.approx(log_panel._log_manager._timestamp_start.toMSecsSinceEpoch() / 1000, 123456789.005)
log_panel._log_manager._timestamp_end = QDateTime(1973, 11, 29, 21, 33, 9, 10, 1)
pytest.approx(log_panel._log_manager._timestamp_end.toMSecsSinceEpoch() / 1000, 123456789.010)
filter_ = log_panel._log_manager._create_timestamp_filter()
assert not filter_(TEST_LOG_MESSAGES[0])
assert filter_(TEST_LOG_MESSAGES[1])
assert not filter_(TEST_LOG_MESSAGES[2])

View File

@@ -3,19 +3,21 @@ from typing import Literal
import pytest
from qtpy.QtCore import QPoint, Qt
from qtpy.QtGui import QContextMenuEvent
from qtpy.QtWidgets import QComboBox, QLabel, QMenu, QToolButton, QWidget
from qtpy.QtWidgets import QComboBox, QLabel, QMenu, QStyle, QToolButton, QWidget
from bec_widgets.qt_utils.toolbar import (
DeviceSelectionAction,
ExpandableMenuAction,
IconAction,
LongPressToolButton,
MaterialIconAction,
ModularToolBar,
QtIconAction,
SeparatorAction,
SwitchableToolBarAction,
ToolbarBundle,
WidgetAction,
)
from tests.unit_tests.conftest import create_widget
@pytest.fixture
@@ -62,6 +64,12 @@ def material_icon_action():
)
@pytest.fixture
def qt_icon_action():
"""Fixture to create a QtIconAction."""
return QtIconAction(standard_icon=QStyle.SP_FileIcon, tooltip="Qt File", checkable=True)
@pytest.fixture
def device_selection_action():
"""Fixture to create a DeviceSelectionAction."""
@@ -89,6 +97,20 @@ def expandable_menu_action():
)
@pytest.fixture
def switchable_toolbar_action():
"""Fixture to create a switchable toolbar action with two MaterialIconActions."""
action1 = MaterialIconAction(icon_name="counter_1", tooltip="Action 1", checkable=True)
action2 = MaterialIconAction(icon_name="counter_2", tooltip="Action 2", checkable=True)
switchable = SwitchableToolBarAction(
actions={"action1": action1, "action2": action2},
initial_action="action1",
tooltip="Switchable Action",
checkable=True,
)
return switchable
def test_initialization(toolbar_fixture):
"""Test that ModularToolBar initializes correctly with different orientations."""
toolbar = toolbar_fixture
@@ -131,7 +153,12 @@ def test_set_orientation(toolbar_fixture, qtbot, dummy_widget):
def test_add_action(
toolbar_fixture, icon_action, separator_action, material_icon_action, dummy_widget
toolbar_fixture,
icon_action,
separator_action,
material_icon_action,
qt_icon_action,
dummy_widget,
):
"""Test adding different types of actions to the toolbar."""
toolbar = toolbar_fixture
@@ -153,6 +180,12 @@ def test_add_action(
assert toolbar.widgets["material_icon_action"] == material_icon_action
assert material_icon_action.action in toolbar.actions()
# Add QtIconAction
toolbar.add_action("qt_icon_action", qt_icon_action, dummy_widget)
assert "qt_icon_action" in toolbar.widgets
assert toolbar.widgets["qt_icon_action"] == qt_icon_action
assert qt_icon_action.action in toolbar.actions()
def test_hide_show_action(toolbar_fixture, icon_action, qtbot, dummy_widget):
"""Test hiding and showing actions on the toolbar."""
@@ -318,79 +351,170 @@ def test_widget_action_calculate_minimum_width(qtbot):
assert width > 100
# FIXME test is stucking CI, works locally
# def test_context_menu_contains_added_actions(
# qtbot, icon_action, material_icon_action, dummy_widget
# ):
# """
# Test that the toolbar's context menu lists all added toolbar actions.
# """
# toolbar = create_widget(
# qtbot, widget=ModularToolBar, target_widget=dummy_widget, orientation="horizontal"
# )
#
# # Add two different actions
# toolbar.add_action("icon_action", icon_action, dummy_widget)
# toolbar.add_action("material_icon_action", material_icon_action, dummy_widget)
#
# # Manually trigger the context menu event
# event = QContextMenuEvent(QContextMenuEvent.Mouse, QPoint(10, 10))
# toolbar.contextMenuEvent(event)
#
# # The QMenu is executed in contextMenuEvent, so we can fetch all possible actions
# # from the displayed menu by searching for QMenu in the immediate children of the toolbar.
# menus = toolbar.findChildren(QMenu)
# assert len(menus) > 0
# menu = menus[-1] # The most recently created menu
#
# menu_action_texts = [action.text() for action in menu.actions()]
# # Check if the menu contains entries for both added actions
# assert any(icon_action.tooltip in text or "icon_action" in text for text in menu_action_texts)
# assert any(
# material_icon_action.tooltip in text or "material_icon_action" in text
# for text in menu_action_texts
# )
# menu.actions()[0].trigger() # Trigger the first action to close the menu
# toolbar.close()
def test_add_action_to_bundle(toolbar_fixture, dummy_widget, material_icon_action):
# Create an initial bundle with one action
bundle = ToolbarBundle(
bundle_id="test_bundle", actions=[("initial_action", material_icon_action)]
)
toolbar_fixture.add_bundle(bundle, dummy_widget)
# Create a new action to add to the existing bundle
new_action = MaterialIconAction(
icon_name="counter_1", tooltip="New Action", checkable=True, parent=dummy_widget
)
toolbar_fixture.add_action_to_bundle("test_bundle", "new_action", new_action, dummy_widget)
# Verify the new action is registered in the toolbar's widgets
assert "new_action" in toolbar_fixture.widgets
assert toolbar_fixture.widgets["new_action"] == new_action
# Verify the new action is included in the bundle tracking
assert "new_action" in toolbar_fixture.bundles["test_bundle"]
assert toolbar_fixture.bundles["test_bundle"][-1] == "new_action"
# Verify the new action's QAction is present in the toolbar's action list
actions_list = toolbar_fixture.actions()
assert new_action.action in actions_list
# Verify that the new action is inserted immediately after the last action of the bundle
last_bundle_action = material_icon_action.action
index_last = actions_list.index(last_bundle_action)
index_new = actions_list.index(new_action.action)
assert index_new == index_last + 1
# FIXME test is stucking CI, works locally
# def test_context_menu_toggle_action_visibility(qtbot, icon_action, dummy_widget):
# """
# Test that toggling action visibility works correctly through the toolbar's context menu.
# """
# toolbar = create_widget(
# qtbot, widget=ModularToolBar, target_widget=dummy_widget, orientation="horizontal"
# )
# # Add an action
# toolbar.add_action("icon_action", icon_action, dummy_widget)
# assert icon_action.action.isVisible()
#
# # Manually trigger the context menu event
# event = QContextMenuEvent(QContextMenuEvent.Mouse, QPoint(10, 10))
# toolbar.contextMenuEvent(event)
#
# # Grab the menu that was created
# menus = toolbar.findChildren(QMenu)
# assert len(menus) > 0
# menu = menus[-1]
#
# # Locate the QAction in the menu
# matching_actions = [m for m in menu.actions() if m.text() == icon_action.tooltip]
# assert len(matching_actions) == 1
# action_in_menu = matching_actions[0]
#
# # Toggle it off (uncheck)
# action_in_menu.setChecked(False)
# menu.triggered.emit(action_in_menu)
# # The action on the toolbar should now be hidden
# assert not icon_action.action.isVisible()
#
# # Toggle it on (check)
# action_in_menu.setChecked(True)
# menu.triggered.emit(action_in_menu)
# # The action on the toolbar should be visible again
# assert icon_action.action.isVisible()
#
# menu.actions()[0].trigger() # Trigger the first action to close the menu
# toolbar.close()
def test_context_menu_contains_added_actions(
toolbar_fixture, icon_action, material_icon_action, dummy_widget, monkeypatch
):
"""
Test that the toolbar's context menu lists all added toolbar actions.
"""
toolbar = toolbar_fixture
# Add two different actions
toolbar.add_action("icon_action", icon_action, dummy_widget)
toolbar.add_action("material_icon_action", material_icon_action, dummy_widget)
# Mock the QMenu.exec_ method to prevent the context menu from being displayed and block CI pipeline
monkeypatch.setattr(QMenu, "exec_", lambda self, pos=None: None)
event = QContextMenuEvent(QContextMenuEvent.Mouse, QPoint(10, 10))
toolbar.contextMenuEvent(event)
menus = toolbar.findChildren(QMenu)
assert len(menus) > 0
menu = menus[-1]
menu_action_texts = [action.text() for action in menu.actions()]
assert any(icon_action.tooltip in text or "icon_action" in text for text in menu_action_texts)
assert any(
material_icon_action.tooltip in text or "material_icon_action" in text
for text in menu_action_texts
)
def test_context_menu_toggle_action_visibility(
toolbar_fixture, icon_action, dummy_widget, monkeypatch
):
"""
Test that toggling action visibility works correctly through the toolbar's context menu.
"""
toolbar = toolbar_fixture
# Add an action
toolbar.add_action("icon_action", icon_action, dummy_widget)
assert icon_action.action.isVisible()
# Manually trigger the context menu event
monkeypatch.setattr(QMenu, "exec_", lambda self, pos=None: None)
event = QContextMenuEvent(QContextMenuEvent.Mouse, QPoint(10, 10))
toolbar.contextMenuEvent(event)
# Grab the menu that was created
menus = toolbar.findChildren(QMenu)
assert len(menus) > 0
menu = menus[-1]
# Locate the QAction in the menu
matching_actions = [m for m in menu.actions() if m.text() == icon_action.tooltip]
assert len(matching_actions) == 1
action_in_menu = matching_actions[0]
# Toggle it off (uncheck)
action_in_menu.setChecked(False)
menu.triggered.emit(action_in_menu)
# The action on the toolbar should now be hidden
assert not icon_action.action.isVisible()
# Toggle it on (check)
action_in_menu.setChecked(True)
menu.triggered.emit(action_in_menu)
# The action on the toolbar should be visible again
assert icon_action.action.isVisible()
def test_switchable_toolbar_action_add(toolbar_fixture, dummy_widget, switchable_toolbar_action):
"""Test that a switchable toolbar action can be added to the toolbar correctly."""
toolbar = toolbar_fixture
toolbar.add_action("switch_action", switchable_toolbar_action, dummy_widget)
# Verify the action was added correctly
assert "switch_action" in toolbar.widgets
assert toolbar.widgets["switch_action"] == switchable_toolbar_action
# Verify the button is present and is the correct type
button = switchable_toolbar_action.main_button
assert isinstance(button, LongPressToolButton)
# Verify initial state
assert switchable_toolbar_action.current_key == "action1"
assert button.toolTip() == "Action 1"
def test_switchable_toolbar_action_switching(
toolbar_fixture, dummy_widget, switchable_toolbar_action, qtbot
):
toolbar = toolbar_fixture
toolbar.add_action("switch_action", switchable_toolbar_action, dummy_widget)
# Verify initial state is set to action1
assert switchable_toolbar_action.current_key == "action1"
assert switchable_toolbar_action.main_button.toolTip() == "Action 1"
# Access the dropdown menu from the main button
menu = switchable_toolbar_action.main_button.menu()
assert menu is not None
# Find the QAction corresponding to "Action 2"
action_for_2 = None
for act in menu.actions():
if act.text() == "Action 2":
action_for_2 = act
break
assert action_for_2 is not None, "Menu action for 'Action 2' not found."
# Trigger the QAction to switch to action2
action_for_2.trigger()
qtbot.wait(100)
# Verify that the switchable action has updated its state
assert switchable_toolbar_action.current_key == "action2"
assert switchable_toolbar_action.main_button.toolTip() == "Action 2"
def test_long_pressbutton(toolbar_fixture, dummy_widget, switchable_toolbar_action, qtbot):
toolbar = toolbar_fixture
toolbar.add_action("switch_action", switchable_toolbar_action, dummy_widget)
# Verify the button is a LongPressToolButton
button = switchable_toolbar_action.main_button
assert isinstance(button, LongPressToolButton)
# Override showMenu() to record when it is called.
call_flag = []
# had to put some fake menu, we cannot call .isVisible at CI
def fake_showMenu():
call_flag.append(True)
button.showMenu = fake_showMenu
# Simulate a long press (exceeding the threshold, default 500ms).
qtbot.mousePress(button, Qt.LeftButton)
qtbot.wait(600) # wait longer than long_press_threshold
qtbot.mouseRelease(button, Qt.LeftButton)
# Verify that fake_showMenu() was called.
assert call_flag, "Long press did not trigger showMenu() as expected."

View File

@@ -271,7 +271,8 @@ def test_multi_waveform_widget_theme_update(qtbot, multi_waveform_widget):
palette = get_theme_palette()
waveform_color_dark = multi_waveform_widget.waveform.plot_item.getAxis("left").pen().color()
bg_color = multi_waveform_widget.fig.backgroundBrush().color()
assert bg_color == QColor("black")
assert bg_color == QColor(20, 20, 20)
assert waveform_color_dark == palette.text().color()
# Set the theme to light
@@ -279,7 +280,7 @@ def test_multi_waveform_widget_theme_update(qtbot, multi_waveform_widget):
palette = get_theme_palette()
waveform_color_light = multi_waveform_widget.waveform.plot_item.getAxis("left").pen().color()
bg_color = multi_waveform_widget.fig.backgroundBrush().color()
assert bg_color == QColor("white")
assert bg_color == QColor(233, 236, 239)
assert waveform_color_light == palette.text().color()
assert waveform_color_dark != waveform_color_light
@@ -291,5 +292,5 @@ def test_multi_waveform_widget_theme_update(qtbot, multi_waveform_widget):
waveform_color = multi_waveform_widget.waveform.plot_item.getAxis("left").pen().color()
bg_color = multi_waveform_widget.fig.backgroundBrush().color()
assert bg_color == QColor("black")
assert bg_color == QColor(20, 20, 20)
assert waveform_color == waveform_color_dark

View File

@@ -1,4 +1,4 @@
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase, UIMode
from .client_mocks import mocked_client
from .conftest import create_widget
@@ -247,3 +247,96 @@ def test_set_method(qtbot, mocked_client):
assert pb.y_grid is True
assert pb.x_log is True
assert pb.outer_axes is True
def test_ui_mode_popup(qtbot, mocked_client):
"""
Test that setting ui_mode to POPUP creates a popup bundle with visible actions
and hides the side panel.
"""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
pb.ui_mode = UIMode.POPUP
# The popup bundle should be created and its actions made visible.
assert "popup_bundle" in pb.toolbar.bundles
for action_id in pb.toolbar.bundles["popup_bundle"]:
assert pb.toolbar.widgets[action_id].action.isVisible() is True
# The side panel should be hidden.
assert not pb.side_panel.isVisible()
def test_ui_mode_side(qtbot, mocked_client):
"""
Test that setting ui_mode to SIDE shows the side panel and ensures any popup actions
are hidden.
"""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
pb.ui_mode = UIMode.SIDE
# If a popup bundle exists, its actions should be hidden.
if "popup_bundle" in pb.toolbar.bundles:
for action_id in pb.toolbar.bundles["popup_bundle"]:
assert pb.toolbar.widgets[action_id].action.isVisible() is False
def test_enable_popups_property(qtbot, mocked_client):
"""
Test the enable_popups property: when enabled, ui_mode should be POPUP,
and when disabled, ui_mode should change to NONE.
"""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
pb.enable_popups = True
assert pb.ui_mode == UIMode.POPUP
# The popup bundle actions should be visible.
assert "popup_bundle" in pb.toolbar.bundles
for action_id in pb.toolbar.bundles["popup_bundle"]:
assert pb.toolbar.widgets[action_id].action.isVisible() is True
pb.enable_popups = False
assert pb.ui_mode == UIMode.NONE
def test_enable_side_panel_property(qtbot, mocked_client):
"""
Test the enable_side_panel property: when enabled, ui_mode should be SIDE,
and when disabled, ui_mode should change to NONE.
"""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
pb.enable_side_panel = True
assert pb.ui_mode == UIMode.SIDE
pb.enable_side_panel = False
assert pb.ui_mode == UIMode.NONE
def test_switching_between_popup_and_side_panel_closes_dialog(qtbot, mocked_client):
"""
Test that if a popup dialog is open (via the axis settings popup) then switching
to side-panel mode closes the dialog.
"""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
pb.ui_mode = UIMode.POPUP
# Open the axis settings popup.
pb.show_axis_settings_popup()
qtbot.wait(100)
# The dialog should now exist and be visible.
assert pb.axis_settings_dialog is not None
assert pb.axis_settings_dialog.isVisible() is True
# Switch to side panel mode.
pb.ui_mode = UIMode.SIDE
qtbot.wait(100)
# The axis settings dialog should be closed (and reference cleared).
assert pb.axis_settings_dialog is None or pb.axis_settings_dialog.isVisible() is False
def test_enable_fps_monitor_property(qtbot, mocked_client):
"""
Test the enable_fps_monitor property: when enabled, the FPS monitor should be hooked
(resulting in a non-None fps_monitor and visible fps_label), and when disabled, the FPS
monitor should be unhooked and the label hidden.
"""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
pb.enable_fps_monitor = True
assert pb.fps_monitor is not None
pb.enable_fps_monitor = False
assert pb.fps_monitor is None

View File

@@ -21,7 +21,9 @@ def test_rpc_server_start_server_without_service_config(mocked_cli_server):
mock_server, mock_config, _ = mocked_cli_server
_start_server("gui_id", BECFigure, None)
mock_server.assert_called_once_with(gui_id="gui_id", config=mock_config(), gui_class=BECFigure)
mock_server.assert_called_once_with(
gui_id="gui_id", config=mock_config(), gui_class=BECFigure, token=None
)
@pytest.mark.parametrize(
@@ -38,4 +40,6 @@ def test_rpc_server_start_server_with_service_config(mocked_cli_server, config,
mock_server, mock_config, _ = mocked_cli_server
config = mock_config(**call_config)
_start_server("gui_id", BECFigure, config)
mock_server.assert_called_once_with(gui_id="gui_id", config=config, gui_class=BECFigure)
mock_server.assert_called_once_with(
gui_id="gui_id", config=config, gui_class=BECFigure, token=None
)

View File

@@ -2,10 +2,12 @@
from unittest.mock import MagicMock
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import AvailableResourceMessage, ScanQueueHistoryMessage, ScanQueueMessage
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.scan_control import ScanControl
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import StrMetadataField
from .client_mocks import mocked_client
@@ -286,8 +288,8 @@ scan_history = ScanQueueHistoryMessage(
@pytest.fixture(scope="function")
def scan_control(qtbot, mocked_client): # , mock_dev):
mocked_client.connector.set("scans/available_scans", available_scans_message)
mocked_client.connector.lpush("internal/queue/queue_history", scan_history)
mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message)
mocked_client.connector.lpush(MessageEndpoints.scan_queue_history(), scan_history)
widget = ScanControl(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
@@ -402,7 +404,7 @@ def test_run_line_scan_with_parameters(scan_control, mocked_client):
expected_device = mocked_client.device_manager.devices.samx
expected_args_list = [expected_device, args["start"], args["stop"]]
assert called_args == tuple(expected_args_list)
assert called_kwargs == kwargs
assert called_kwargs == kwargs | {"metadata": {"sample_name": ""}}
# Check the emitted signal
mock_slot.assert_called_once()
@@ -478,7 +480,7 @@ def test_run_grid_scan_with_parameters(scan_control, mocked_client):
args_row2["steps"],
]
assert called_args == tuple(expected_args_list)
assert called_kwargs == kwargs
assert called_kwargs == kwargs | {"metadata": {"sample_name": ""}}
# Check the emitted signal
mock_slot.assert_called_once()
@@ -531,3 +533,22 @@ def test_get_scan_parameters_from_redis(scan_control, mocked_client):
assert args == ["samx", 0.0, 2.0]
assert kwargs == {"steps": 10, "relative": False, "exp_time": 2.0, "burst_at_each_point": 1}
def test_scan_metadata_is_connected(scan_control):
assert scan_control._metadata_form._scan_name == "line_scan"
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
assert scan_control._metadata_form._scan_name == "grid_scan"
sample_name = scan_control._metadata_form._md_grid_layout.itemAtPosition(0, 1).widget()
assert isinstance(sample_name, StrMetadataField)
sample_name._main_widget.setText("Test Sample")
scan_control._metadata_form._additional_metadata._table_model._data = [
["test key 1", "test value 1"],
["test key 2", "test value 2"],
]
scan_control._metadata_form.validate_form()
assert scan_control._scan_metadata == {
"sample_name": "Test Sample",
"test key 1": "test value 1",
"test key 2": "test value 2",
}

View File

@@ -0,0 +1,210 @@
from decimal import Decimal
from typing import Annotated
import pytest
from bec_lib.metadata_schema import BasicScanMetadata
from pydantic import Field
from pydantic.types import Json
from qtpy.QtCore import QItemSelectionModel, QPoint, Qt
from bec_widgets.widgets.editors.scan_metadata import ScanMetadata
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import (
BoolMetadataField,
FloatDecimalMetadataField,
IntMetadataField,
MetadataWidget,
StrMetadataField,
)
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
AdditionalMetadataTable,
)
class ExampleSchema(BasicScanMetadata):
str_optional: str | None = Field(
None, title="Optional string", description="an optional string", max_length=23
)
str_required: str
bool_optional: bool | None = Field(None)
bool_required_default: bool = Field(True)
bool_required_nodefault: bool = Field()
int_default: int = Field(123)
int_nodefault_optional: int | None = Field(lt=-1, ge=-44)
float_nodefault: float
decimal_dp_limits_nodefault: Decimal = Field(Decimal(1.23), decimal_places=2, gt=1, le=34.5)
unsupported_class: Json = Field(default_factory=dict)
pytest.approx(0.1)
TEST_DICT = {
"sample_name": "test name",
"str_optional": None,
"str_required": "something",
"bool_optional": None,
"bool_required_default": True,
"bool_required_nodefault": False,
"int_default": 21,
"int_nodefault_optional": -10,
"float_nodefault": pytest.approx(0.1),
"decimal_dp_limits_nodefault": pytest.approx(34),
"unsupported_class": '{"key": "value"}',
}
@pytest.fixture
def example_md():
return ExampleSchema.model_validate(TEST_DICT)
@pytest.fixture
def empty_metadata_widget():
widget = ScanMetadata()
widget._additional_metadata._table_model._data = [["extra_field", "extra_data"]]
yield widget
widget._clear_grid()
widget.deleteLater()
@pytest.fixture
def metadata_widget(empty_metadata_widget: ScanMetadata):
widget = empty_metadata_widget
widget._md_schema = ExampleSchema
widget.populate()
sample_name = widget._md_grid_layout.itemAtPosition(0, 1).widget()
str_optional = widget._md_grid_layout.itemAtPosition(1, 1).widget()
str_required = widget._md_grid_layout.itemAtPosition(2, 1).widget()
bool_optional = widget._md_grid_layout.itemAtPosition(3, 1).widget()
bool_required_default = widget._md_grid_layout.itemAtPosition(4, 1).widget()
bool_required_nodefault = widget._md_grid_layout.itemAtPosition(5, 1).widget()
int_default = widget._md_grid_layout.itemAtPosition(6, 1).widget()
int_nodefault_optional = widget._md_grid_layout.itemAtPosition(7, 1).widget()
float_nodefault = widget._md_grid_layout.itemAtPosition(8, 1).widget()
decimal_dp_limits_nodefault = widget._md_grid_layout.itemAtPosition(9, 1).widget()
unsupported_class = widget._md_grid_layout.itemAtPosition(10, 1).widget()
yield (
widget,
{
"sample_name": sample_name,
"str_optional": str_optional,
"str_required": str_required,
"bool_optional": bool_optional,
"bool_required_default": bool_required_default,
"bool_required_nodefault": bool_required_nodefault,
"int_default": int_default,
"int_nodefault_optional": int_nodefault_optional,
"float_nodefault": float_nodefault,
"decimal_dp_limits_nodefault": decimal_dp_limits_nodefault,
"unsupported_class": unsupported_class,
},
)
def fill_commponents(components: dict[str, MetadataWidget]):
components["sample_name"].setValue("test name")
components["str_optional"].setValue(None)
components["str_required"].setValue("something")
components["bool_optional"].setValue(None)
components["bool_required_nodefault"].setValue(False)
components["int_default"].setValue(21)
components["int_nodefault_optional"].setValue(-10)
components["float_nodefault"].setValue(0.1)
components["decimal_dp_limits_nodefault"].setValue(456.789)
components["unsupported_class"].setValue(r'{"key": "value"}')
def test_griditems_are_correct_class(
metadata_widget: tuple[ScanMetadata, dict[str, MetadataWidget]]
):
_, components = metadata_widget
assert isinstance(components["sample_name"], StrMetadataField)
assert isinstance(components["str_optional"], StrMetadataField)
assert isinstance(components["str_required"], StrMetadataField)
assert isinstance(components["bool_optional"], BoolMetadataField)
assert isinstance(components["bool_required_default"], BoolMetadataField)
assert isinstance(components["bool_required_nodefault"], BoolMetadataField)
assert isinstance(components["int_default"], IntMetadataField)
assert isinstance(components["int_nodefault_optional"], IntMetadataField)
assert isinstance(components["float_nodefault"], FloatDecimalMetadataField)
assert isinstance(components["decimal_dp_limits_nodefault"], FloatDecimalMetadataField)
assert isinstance(components["unsupported_class"], StrMetadataField)
def test_grid_to_dict(metadata_widget: tuple[ScanMetadata, dict[str, MetadataWidget]]):
widget, components = metadata_widget = metadata_widget
fill_commponents(components)
assert widget._dict_from_grid() == TEST_DICT
assert widget.get_full_model_dict() == TEST_DICT | {"extra_field": "extra_data"}
def test_validation(metadata_widget: tuple[ScanMetadata, dict[str, MetadataWidget]]):
widget, components = metadata_widget = metadata_widget
assert widget._validity.compact_status.styleSheet().startswith(
widget._validity.compact_status.default_led[:114]
)
fill_commponents(components)
widget.validate_form()
assert widget._validity_message.text() == "No errors!"
components["bool_required_nodefault"]._main_widget.clear()
widget.validate_form()
assert "Input should be a valid boolean" in widget._validity_message.text()
components["bool_required_nodefault"].setValue(True)
components["float_nodefault"]._main_widget.clear()
widget.validate_form()
assert "Input should be a valid number" in widget._validity_message.text()
components["float_nodefault"].setValue(True)
def test_numbers_clipped_to_limits(metadata_widget: tuple[ScanMetadata, dict[str, MetadataWidget]]):
widget, components = metadata_widget = metadata_widget
fill_commponents(components)
components["decimal_dp_limits_nodefault"].setValue(-56)
widget.validate_form()
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(2)
assert widget._validity_message.text() == "No errors!"
@pytest.fixture
def table():
table = AdditionalMetadataTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
yield table
table._table_model.deleteLater()
table._table_view.deleteLater()
table.deleteLater()
def test_additional_metadata_table_add_row(table: AdditionalMetadataTable):
assert table._table_model.rowCount() == 3
table._add_button.click()
assert table._table_model.rowCount() == 4
def test_additional_metadata_table_delete_row(table: AdditionalMetadataTable):
assert table._table_model.rowCount() == 3
m = table._table_view.selectionModel()
item = table._table_view.indexAt(QPoint(0, 0)).siblingAtRow(1)
m.select(item, QItemSelectionModel.SelectionFlag.Select)
table.delete_selected_rows()
assert table._table_model.rowCount() == 2
assert list(table.dump_dict().keys()) == ["key1", "key3"]
def test_additional_metadata_allows_changes(table: AdditionalMetadataTable):
assert table._table_model.rowCount() == 3
assert list(table.dump_dict().keys()) == ["key1", "key2", "key3"]
table._table_model.setData(table._table_model.index(1, 0), "key4", Qt.ItemDataRole.EditRole)
assert list(table.dump_dict().keys()) == ["key1", "key4", "key3"]
def test_additional_metadata_doesnt_allow_dupes(table: AdditionalMetadataTable):
assert table._table_model.rowCount() == 3
assert list(table.dump_dict().keys()) == ["key1", "key2", "key3"]
table._table_model.setData(table._table_model.index(1, 0), "key1", Qt.ItemDataRole.EditRole)
assert list(table.dump_dict().keys()) == ["key1", "key2", "key3"]

View File

@@ -694,7 +694,8 @@ def test_waveform_async_data_update(qtbot, mocked_client):
# w1.queue.scan_storage.find_scan_by_ID.return_value = scan_item_mock
msg_1 = {"signals": {"async_device": {"value": [7, 8, 9]}}}
w1.on_async_readback(msg_1, {"async_update": "extend"})
metadata_1 = {"async_update": {"max_shape": [None], "type": "add"}}
w1.on_async_readback(msg_1, metadata_1)
qtbot.wait(200)
x_data, y_data = w1.curves[0].get_data()
@@ -703,7 +704,7 @@ def test_waveform_async_data_update(qtbot, mocked_client):
assert w1.plot_item.getAxis("bottom").labelText == custom_label + " [best_effort]"
msg_2 = {"signals": {"async_device": {"value": [10, 11, 12]}}}
w1.on_async_readback(msg_2, {"async_update": "extend"})
w1.on_async_readback(msg_2, metadata_1)
qtbot.wait(200)
x_data, y_data = w1.curves[0].get_data()
@@ -712,7 +713,8 @@ def test_waveform_async_data_update(qtbot, mocked_client):
assert w1.plot_item.getAxis("bottom").labelText == custom_label + " [best_effort]"
msg_3 = {"signals": {"async_device": {"value": [20, 21, 22]}}}
w1.on_async_readback(msg_3, {"async_update": "replace"})
metadata_3 = {"async_update": {"max_shape": [None], "type": "replace"}}
w1.on_async_readback(msg_3, metadata_3)
qtbot.wait(200)
x_data, y_data = w1.curves[0].get_data()

View File

@@ -484,7 +484,7 @@ def test_waveform_widget_theme_update(qtbot, waveform_widget):
palette = get_theme_palette()
waveform_color_dark = waveform_widget.waveform.plot_item.getAxis("left").pen().color()
bg_color = waveform_widget.fig.backgroundBrush().color()
assert bg_color == QColor("black")
assert bg_color == QColor(20, 20, 20)
assert waveform_color_dark == palette.text().color()
# Set the theme to light; equivalent to clicking the light mode button
@@ -493,7 +493,7 @@ def test_waveform_widget_theme_update(qtbot, waveform_widget):
palette = get_theme_palette()
waveform_color_light = waveform_widget.waveform.plot_item.getAxis("left").pen().color()
bg_color = waveform_widget.fig.backgroundBrush().color()
assert bg_color == QColor("white")
assert bg_color == QColor(233, 236, 239)
assert waveform_color_light == palette.text().color()
assert waveform_color_dark != waveform_color_light
@@ -509,7 +509,7 @@ def test_waveform_widget_theme_update(qtbot, waveform_widget):
# we compare the waveform color to the dark theme color
waveform_color = waveform_widget.waveform.plot_item.getAxis("left").pen().color()
bg_color = waveform_widget.fig.backgroundBrush().color()
assert bg_color == QColor("black")
assert bg_color == QColor(20, 20, 20)
assert waveform_color == waveform_color_dark