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

Compare commits

...

18 Commits

Author SHA1 Message Date
4947549a20 test: update client and server tests to include token and ACL data parameters 2025-03-10 16:56:49 +01:00
59e4c2f612 WIP - feat(acl): implement ACL handling and token encryption in BECWidgetsCLIServer and client utilities 2025-03-07 22:11:34 +01:00
semantic-release
15e11b287d 1.25.0
Automatically generated by python-semantic-release
2025-03-07 15:19:37 +00:00
7cbebbb1f0 feat(waveform): add slice handling and reset functionality for async updates 2025-03-07 15:44:46 +01:00
semantic-release
66f4f9bfa8 1.24.5
Automatically generated by python-semantic-release
2025-03-06 14:51:03 +00:00
66c6c7fa50 fix: add support for additional keyword arguments in widget constructors 2025-03-06 15:39:16 +01:00
semantic-release
31c3337300 1.24.4
Automatically generated by python-semantic-release
2025-03-05 19:59:54 +00:00
2c506ee3c8 fix(cli/server): handle RedisError during heartbeat emission to properly close the app even if the Redis connection is lost 2025-03-05 20:41:33 +01:00
semantic-release
25423f4a3a 1.24.3
Automatically generated by python-semantic-release
2025-03-05 09:46:53 +00:00
fa91366dcb fix(multi_waveform): update on_async_readback to use structured metadata for async updates with "add" instead of "extend" 2025-03-04 22:31:14 +01:00
semantic-release
4db0f9f10c 1.24.2
Automatically generated by python-semantic-release
2025-02-27 10:08:57 +00:00
46b1a228be fix(e2e): added wait time to flaky e2e 2025-02-27 10:54:36 +01:00
semantic-release
531018b0ac 1.24.1
Automatically generated by python-semantic-release
2025-02-26 21:06:09 +00:00
8679b5f08b test: extended test coverage for axis settings, plot base and qt toolbar action 2025-02-26 21:54:33 +01:00
6f2c2401ac refactor(plot_base): toolbar buttons adapted for the Switch actions from toolbar; plot export and mouse modes consolidated into one switch button 2025-02-26 21:54:33 +01:00
6d1106e33e fix(toolbar): Switch Actions for default checked actions fixed 2025-02-26 21:54:33 +01:00
90a184643a refactor(axis_settings): spinbox migrated to new BECSpinBoxes 2025-02-26 21:54:33 +01:00
3aa2f2225f fix(plot_base): ability to choose between popup or side panel gui mode 2025-02-26 21:54:33 +01:00
57 changed files with 884 additions and 375 deletions

View File

@@ -1,6 +1,73 @@
# 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

View File

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

View File

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

View File

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

View File

@@ -251,6 +251,7 @@ class SwitchableToolBarAction(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)
@@ -258,6 +259,7 @@ class SwitchableToolBarAction(ToolBarAction):
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] = {}
@@ -283,7 +285,7 @@ class SwitchableToolBarAction(ToolBarAction):
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_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)
@@ -293,7 +295,7 @@ class SwitchableToolBarAction(ToolBarAction):
action_obj = self.actions[self.current_key]
action_obj.action.trigger()
def _set_default_action(self, key: str):
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())
@@ -929,7 +931,7 @@ class MainWindow(QMainWindow): # pragma: no cover
actions={"action1": action1, "action2": action2},
initial_action="action1",
tooltip="Switchable Action",
checkable=False,
checkable=True,
parent=self,
)
self.toolbar.add_action("switchable_action_no_toggle", switchable_action, self)
@@ -940,6 +942,7 @@ class MainWindow(QMainWindow): # pragma: no cover
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()

View File

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

View File

@@ -162,13 +162,14 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
config: Optional[FigureConfig] = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
) -> None:
if config is None:
config = FigureConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = FigureConfig(**config)
super().__init__(client=client, gui_id=gui_id)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
pg.GraphicsLayoutWidget.__init__(self, parent)
self.widget_handler = WidgetHandler()
@@ -573,10 +574,7 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
config=config,
**axis_kwargs,
)
# has to be changed manually to ensure unique id, if config is copied from existing widget, the id could be
# used otherwise multiple times
widget.set_gui_id(widget_id)
widget.config.row = row
widget.config.col = col
@@ -588,6 +586,7 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
self.config.num_cols = max(self.config.num_cols, col + 1)
# Saving config for future referencing
self.config.widgets[widget_id] = widget.config
self._widgets[widget_id] = widget

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,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

View File

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

View File

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

View File

@@ -49,8 +49,9 @@ class ScanMetadata(BECWidget, QWidget):
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,18 @@
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.fps_counter import FPSCounter
@@ -20,7 +24,6 @@ from bec_widgets.widgets.plots_next_gen.toolbar_bundles.mouse_interactions impor
)
from bec_widgets.widgets.plots_next_gen.toolbar_bundles.plot_export import PlotExportBundle
from bec_widgets.widgets.plots_next_gen.toolbar_bundles.roi_bundle import ROIBundle
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
logger = bec_logger.logger
@@ -42,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
@@ -59,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
@@ -74,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)
@@ -82,12 +95,14 @@ 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.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()
@@ -115,13 +130,14 @@ 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) #TODO ATM disabled, cannot be used in DockArea, which is exposed to the user
@@ -133,33 +149,130 @@ class PlotBase(BECWidget, QWidget):
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_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)
)
# 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:
@@ -167,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:
@@ -591,16 +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)
widget = PlotBase()
widget.show()
# Just some example data and parameters to test
widget.y_grid = True
widget.plot_item.plot([1, 2, 3, 4, 5], [1, 2, 3, 4, 5])
window = DemoPlotBase()
window.show()
sys.exit(app.exec_())

View File

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

View File

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

View File

@@ -20,19 +20,6 @@
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="x_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
@@ -64,19 +51,6 @@
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="x_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="x_min_label">
<property name="text">
@@ -98,6 +72,12 @@
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="BECSpinBox" name="x_min"/>
</item>
<item row="2" column="2">
<widget class="BECSpinBox" name="x_max"/>
</item>
</layout>
</widget>
</item>
@@ -128,19 +108,6 @@
<string>Y Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="y_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="y_min_label">
<property name="text">
@@ -148,19 +115,6 @@
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="y_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="y_label"/>
</item>
@@ -206,6 +160,12 @@
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="BECSpinBox" name="y_min"/>
</item>
<item row="2" column="2">
<widget class="BECSpinBox" name="y_max"/>
</item>
</layout>
</widget>
</item>
@@ -229,6 +189,11 @@
</layout>
</widget>
<customwidgets>
<customwidget>
<class>BECSpinBox</class>
<extends>QDoubleSpinBox</extends>
<header>bec_spin_box</header>
</customwidget>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,10 +26,11 @@ class BECSpinBox(BECWidget, QDoubleSpinBox):
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)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
QDoubleSpinBox.__init__(self, parent=parent)
self.setObjectName("BECSpinBox")

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "1.24.0"
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",
]

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ 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,
@@ -12,6 +12,7 @@ from bec_widgets.qt_utils.toolbar import (
LongPressToolButton,
MaterialIconAction,
ModularToolBar,
QtIconAction,
SeparatorAction,
SwitchableToolBarAction,
ToolbarBundle,
@@ -63,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."""
@@ -146,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
@@ -168,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."""

View File

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

View File

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

View File

@@ -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()