mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-14 20:50:55 +02:00
Compare commits
42 Commits
v1.21.2
...
feature/ac
| Author | SHA1 | Date | |
|---|---|---|---|
| 4947549a20 | |||
| 59e4c2f612 | |||
|
|
15e11b287d | ||
| 7cbebbb1f0 | |||
|
|
66f4f9bfa8 | ||
| 66c6c7fa50 | |||
|
|
31c3337300 | ||
| 2c506ee3c8 | |||
|
|
25423f4a3a | ||
| fa91366dcb | |||
|
|
4db0f9f10c | ||
| 46b1a228be | |||
|
|
531018b0ac | ||
| 8679b5f08b | |||
| 6f2c2401ac | |||
| 6d1106e33e | |||
| 90a184643a | |||
| 3aa2f2225f | |||
|
|
f54e69f1cf | ||
| 7309c1dede | |||
| 1c0021f98b | |||
| d32952a0d5 | |||
| 5206528fec | |||
| 42665b69c5 | |||
|
|
209c898e3d | ||
| 6a43554f3b | |||
|
|
95c931af0b | ||
| f19d9485df | |||
|
|
575c988c4f | ||
| 6b08f7cfb2 | |||
| 6ae33a23a6 | |||
| facb8c30ff | |||
| 333570ba2f | |||
| ef36a7124d | |||
| c2c022154b | |||
| 4c4f1592c2 | |||
|
|
d7fb291877 | ||
| ae18279685 | |||
| 97c0ed53df | |||
| ff8e282034 | |||
|
|
440f36f289 | ||
| 0addef5f17 |
163
CHANGELOG.md
163
CHANGELOG.md
@@ -1,6 +1,169 @@
|
||||
# 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
|
||||
|
||||
@@ -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):
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
72
bec_widgets/qt_utils/expandable_frame.py
Normal file
72
bec_widgets/qt_utils/expandable_frame.py
Normal 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
|
||||
)
|
||||
)
|
||||
@@ -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():
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -6,9 +6,9 @@ from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ig
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QTableView,
|
||||
QSizePolicy,
|
||||
QTreeView,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -105,9 +105,12 @@ class AdditionalMetadataTable(QWidget):
|
||||
self._layout = QHBoxLayout()
|
||||
self.setLayout(self._layout)
|
||||
self._table_model = AdditionalMetadataTableModel(initial_data)
|
||||
self._table_view = QTableView()
|
||||
self._table_view = QTreeView()
|
||||
self._table_view.setModel(self._table_model)
|
||||
self._table_view.horizontalHeader().setStretchLastSection(True)
|
||||
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()
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
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,
|
||||
@@ -18,7 +21,8 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
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 (
|
||||
@@ -36,35 +40,49 @@ class ScanMetadata(BECWidget, QWidget):
|
||||
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)
|
||||
super().__init__(client=client, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self.set_schema(scan_name)
|
||||
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
self._layout.addWidget(QLabel("<b>Required scan metadata:</b>"))
|
||||
|
||||
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._layout.addWidget(self._md_grid)
|
||||
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._layout.addWidget(QLabel("<b>Additional metadata:</b>"))
|
||||
|
||||
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._layout.addWidget(self._additional_metadata)
|
||||
self._additional_md_box_layout.addWidget(self._additional_metadata)
|
||||
|
||||
self._validity = CompactPopupWidget()
|
||||
self._validity.compact_view = True # type: ignore
|
||||
self._validity.label = "Validity" # 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)
|
||||
)
|
||||
@@ -80,14 +98,20 @@ class ScanMetadata(BECWidget, QWidget):
|
||||
self.populate()
|
||||
self.validate_form()
|
||||
|
||||
def validate_form(self, *_):
|
||||
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:
|
||||
self._md_schema.model_validate(self.get_full_model_dict())
|
||||
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"""
|
||||
@@ -140,6 +164,20 @@ class ScanMetadata(BECWidget, QWidget):
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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_())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
0
bec_widgets/widgets/utility/spinbox/__init__.py
Normal file
0
bec_widgets/widgets/utility/spinbox/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
{'files': ['decimal_spinbox.py']}
|
||||
54
bec_widgets/widgets/utility/spinbox/bec_spin_box_plugin.py
Normal file
54
bec_widgets/widgets/utility/spinbox/bec_spin_box_plugin.py
Normal 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()
|
||||
84
bec_widgets/widgets/utility/spinbox/decimal_spinbox.py
Normal file
84
bec_widgets/widgets/utility/spinbox/decimal_spinbox.py
Normal 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())
|
||||
15
bec_widgets/widgets/utility/spinbox/register_bec_spin_box.py
Normal file
15
bec_widgets/widgets/utility/spinbox/register_bec_spin_box.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "1.21.2"
|
||||
version = "1.25.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -24,6 +24,7 @@ dependencies = [
|
||||
"pyte", # needed for vt100 console
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtpy~=2.4",
|
||||
"cryptography~=44.0",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={}))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
68
tests/unit_tests/test_decimal_spin_box.py
Normal file
68
tests/unit_tests/test_decimal_spin_box.py
Normal 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
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from bec_lib.messages import AvailableResourceMessage, ScanQueueHistoryMessage,
|
||||
|
||||
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
|
||||
|
||||
@@ -403,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()
|
||||
@@ -479,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()
|
||||
@@ -532,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",
|
||||
}
|
||||
|
||||
@@ -5,10 +5,9 @@ import pytest
|
||||
from bec_lib.metadata_schema import BasicScanMetadata
|
||||
from pydantic import Field
|
||||
from pydantic.types import Json
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QCheckBox, QDoubleSpinBox, QLineEdit, QSpinBox, QWidget
|
||||
from qtpy.QtCore import QItemSelectionModel, QPoint, Qt
|
||||
|
||||
from bec_widgets.widgets.editors.scan_metadata import AdditionalMetadataTableModel, ScanMetadata
|
||||
from bec_widgets.widgets.editors.scan_metadata import ScanMetadata
|
||||
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import (
|
||||
BoolMetadataField,
|
||||
FloatDecimalMetadataField,
|
||||
@@ -189,7 +188,9 @@ def test_additional_metadata_table_add_row(table: AdditionalMetadataTable):
|
||||
|
||||
def test_additional_metadata_table_delete_row(table: AdditionalMetadataTable):
|
||||
assert table._table_model.rowCount() == 3
|
||||
table._table_view.selectRow(1)
|
||||
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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user