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

Compare commits

..

1 Commits

Author SHA1 Message Date
de5a58a63b feat: add messaging config view 2026-03-20 14:49:16 +01:00
39 changed files with 986 additions and 169 deletions

View File

@@ -1,25 +1,6 @@
# CHANGELOG
## v3.3.2 (2026-03-22)
### Bug Fixes
- Typos
([`3d29a67`](https://github.com/bec-project/bec_widgets/commit/3d29a67c0b2175f2f29b8e5a7befce55f3d28fd3))
## v3.3.1 (2026-03-20)
### Bug Fixes
- **dap_combobox**: Added safeguard for no DAP models
([`79af15a`](https://github.com/bec-project/bec_widgets/commit/79af15a88b993cd5b6bf730796f995f20cf6f188))
- **dap_combobox**: Rewritten as proper combobox
([`90222f3`](https://github.com/bec-project/bec_widgets/commit/90222f30821f822eb24b0179401d4e43050e0156))
## v3.3.0 (2026-03-20)
### Bug Fixes

View File

@@ -263,7 +263,7 @@ class BECMainApp(BECMainWindow):
developer_view_step = self.guided_tour.register_widget(
widget=sidebar_developer_view,
title="Developer View",
text="Click here to access the Developer view to write scripts and macros.",
text="Click here to access the Developer view to write scripts and makros.",
)
tour_steps.append(developer_view_step)

View File

@@ -169,7 +169,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
self._upload_redis_dialog: UploadRedisDialog | None = None
self._dialog_validation_connection: QMetaObject.Connection | None = None
# NOTE: We need here a separate config helper instance to avoid conflicts with
# NOTE: We need here a seperate config helper instance to avoid conflicts with
# other communications to REDIS as uploading a config through a CommunicationConfigAction
# will block if we use the config_helper from self.client.config._config_helper
self._config_helper = config_helper.ConfigHelper(self.client.connector)
@@ -607,8 +607,8 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
self.device_table_view._is_config_in_sync_with_redis()
)
validation_results = self.device_table_view.get_validation_results()
for config, config_status, connection_status in validation_results.values():
if connection_status == ConnectionStatus.CONNECTED.value:
for config, config_status, connnection_status in validation_results.values():
if connnection_status == ConnectionStatus.CONNECTED.value:
self.device_table_view.update_device_validation(
config, config_status, ConnectionStatus.CAN_CONNECT, ""
)

View File

@@ -21,7 +21,7 @@ logger = bec_logger.logger
class _WidgetsEnumType(str, enum.Enum):
"""Enum for the available widgets, to be generated programmatically"""
"""Enum for the available widgets, to be generated programatically"""
...
@@ -985,7 +985,7 @@ class Curve(RPCBase):
class DapComboBox(RPCBase):
"""Editable combobox listing the available DAP models."""
"""The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC."""
@rpc_call
def select_y_axis(self, y_axis: str):
@@ -1011,7 +1011,7 @@ class DapComboBox(RPCBase):
Slot to update the fit model.
Args:
fit_name(str): Fit model name.
default_device(str): Default device name.
"""

View File

@@ -94,7 +94,7 @@ logger = bec_logger.logger
if self._base:
self.content += """
class _WidgetsEnumType(str, enum.Enum):
\"\"\" Enum for the available widgets, to be generated programmatically \"\"\"
\"\"\" Enum for the available widgets, to be generated programatically \"\"\"
...
"""

View File

@@ -167,7 +167,7 @@ class BECConnector:
)
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
# If the gui_id is passed, it should be respected. However, this should be revisited since
# If the gui_id is passed, it should be respected. However, this should be revisted since
# the gui_id has to be unique, and may no longer be.
if gui_id:
self.config.gui_id = gui_id
@@ -399,7 +399,7 @@ class BECConnector:
"""
self.config = config
# FIXME some thoughts are required to decide how this should work with rpc registry
# FIXME some thoughts are required to decide how thhis should work with rpc registry
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
"""
Apply the configuration to the widget.
@@ -417,7 +417,7 @@ class BECConnector:
else:
self.gui_id = self.config.gui_id
# FIXME some thoughts are required to decide how this should work with rpc registry
# FIXME some thoughts are required to decide how thhis should work with rpc registry
def load_config(self, path: str | None = None, gui: bool = False):
"""
Load the configuration of the widget from YAML.

View File

@@ -43,7 +43,7 @@ class WidgetContainerUtils:
if list_of_names is None:
list_of_names = []
ii = 0
while ii < 1000: # 1000 is arbitrary!
while ii < 1000: # 1000 is arbritrary!
name_candidate = f"{name}_{ii}"
if name_candidate not in list_of_names:
return name_candidate

View File

@@ -71,7 +71,7 @@ class FormItemSpec(BaseModel):
"""
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
to store most annotation info, since one of the main purposes is to store data for
forms generated from pydantic models, but can also be composed from other sources or by hand.
forms genrated from pydantic models, but can also be composed from other sources or by hand.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -192,7 +192,7 @@ class DynamicFormItem(QWidget):
@abstractmethod
def _add_main_widget(self) -> None:
self._main_widget: QWidget
"""Add the main data entry widget to self._main_widget and apply any
"""Add the main data entry widget to self._main_widget and appply any
constraints from the field info"""
@SafeSlot()

View File

@@ -15,7 +15,7 @@ class Kind(IFBase):
"""
This is used in the .kind attribute of all OphydObj (Signals, Devices).
A Device examines its components' .kind attribute to decide whether to
A Device examines its components' .kind atttribute to decide whether to
traverse it in read(), read_configuration(), or neither. Additionally, if
decides whether to include its name in `hints['fields']`.
"""

View File

@@ -156,7 +156,7 @@ class RPCServer:
if method == "raise" and hasattr(
obj, "setWindowState"
): # special case for raising windows, should work even if minimized
# this is a special case for raising windows for gnome on Red Hat (RHEL) 9 systems where changing focus is suppressed by default
# this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
# The procedure is as follows:
# 1. Get the current window state to check if the window is minimized and remove minimized flag
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
@@ -442,5 +442,5 @@ class RPCServer:
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
logger.info("Succeeded in shutting down CLI server")
logger.info("Succeded in shutting down CLI server")
self.client.shutdown()

View File

@@ -224,7 +224,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device))
def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None:
"""Toggle enable/disable on available buttons
"""Toogle enable/disable on available buttons
Args:
enable (bool): Enable buttons

View File

@@ -13,7 +13,7 @@ if TYPE_CHECKING:
from .available_device_group import AvailableDeviceGroup
class _DeviceListWidget(QListWidget):
class _DeviceListWiget(QListWidget):
def _item_iter(self):
return (self.item(i) for i in range(self.count()))
@@ -44,7 +44,7 @@ class Ui_AvailableDeviceGroup(object):
self.n_included.setObjectName("n_included")
title_layout.addWidget(self.n_included)
self.device_list = _DeviceListWidget(AvailableDeviceGroup)
self.device_list = _DeviceListWiget(AvailableDeviceGroup)
self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
self.device_list.setObjectName("device_list")
self.device_list.setFrameStyle(0)

View File

@@ -34,13 +34,13 @@ class HashModel(str, Enum):
class DeviceResourceBackend(Protocol):
@property
def tag_groups(self) -> dict[str, set[HashableDevice]]:
"""A dictionary of all available devices separated by tag groups. The same device may
"""A dictionary of all availble devices separated by tag groups. The same device may
appear more than once (in different groups)."""
...
@property
def all_devices(self) -> set[HashableDevice]:
"""A set of all available devices. The same device may not appear more than once."""
"""A set of all availble devices. The same device may not appear more than once."""
...
@property

View File

@@ -347,14 +347,14 @@ class ScanGroupBox(QGroupBox):
def get_parameters(self, device_object: bool = True):
"""
Returns the parameters from the widgets in the scan control layout formatted to run scan from BEC.
Returns the parameters from the widgets in the scan control layout formated to run scan from BEC.
"""
if self.box_type == "args":
return self._get_arg_parameters(device_object=device_object)
return self._get_arg_parameterts(device_object=device_object)
elif self.box_type == "kwargs":
return self._get_kwarg_parameters(device_object=device_object)
def _get_arg_parameters(self, device_object: bool = True):
def _get_arg_parameterts(self, device_object: bool = True):
args = []
for i in range(1, self.layout.rowCount()):
for j in range(self.layout.columnCount()):

View File

@@ -2,19 +2,22 @@
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QComboBox
from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger
class DapComboBox(BECWidget, QComboBox):
class DapComboBox(BECWidget, QWidget):
"""
Editable combobox listing the available DAP models.
The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC.
The widget behaves as a plain QComboBox and keeps ``fit_model_combobox`` as an alias to itself
for backwards compatibility with older call sites.
Args:
parent: Parent widget.
client: BEC client object.
gui_id: GUI ID.
default: Default device name.
"""
ICON_NAME = "data_exploration"
@@ -42,20 +45,19 @@ class DapComboBox(BECWidget, QComboBox):
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
self.fit_model_combobox = self # Just for backwards compatibility with older call sites, the widget itself is the combobox
self._available_models: list[str] = []
self.layout = QVBoxLayout(self)
self.fit_model_combobox = QComboBox(self)
self.layout.addWidget(self.fit_model_combobox)
self.layout.setContentsMargins(0, 0, 0, 0)
self._available_models = None
self._x_axis = None
self._y_axis = None
self._is_valid_input = False
self.setEditable(True)
self.populate_fit_model_combobox()
self.currentTextChanged.connect(self._on_text_changed)
self.fit_model_combobox.currentTextChanged.connect(self._update_current_fit)
# Set default fit model
self.select_default_fit(default_fit)
self.check_validity(self.currentText())
def select_default_fit(self, default_fit: str | None = "GaussianModel"):
def select_default_fit(self, default_fit: str | None):
"""Set the default fit model.
Args:
@@ -63,8 +65,8 @@ class DapComboBox(BECWidget, QComboBox):
"""
if self._validate_dap_model(default_fit):
self.select_fit_model(default_fit)
elif self.available_models:
self.select_fit_model(self.available_models[0])
else:
self.select_fit_model("GaussianModel")
@property
def available_models(self):
@@ -112,40 +114,12 @@ class DapComboBox(BECWidget, QComboBox):
self._y_axis = y_axis
self.y_axis_updated.emit(y_axis)
@Slot(str)
def _on_text_changed(self, fit_name: str):
"""
Validate and emit updates for the current text.
Args:
fit_name(str): The current text in the combobox, representing the selected fit model.
"""
self.check_validity(fit_name)
if not self._is_valid_input:
return
def _update_current_fit(self, fit_name: str):
"""Update the current fit."""
self.fit_model_updated.emit(fit_name)
if self.x_axis is not None and self.y_axis is not None:
self.new_dap_config.emit(self._x_axis, self._y_axis, fit_name)
@Slot(str)
def check_validity(self, fit_name: str):
"""
Highlight invalid manual entries similarly to DeviceComboBox.
Args:
fit_name(str): The current text in the combobox, representing the selected fit model.
"""
if self._validate_dap_model(fit_name):
self._is_valid_input = True
self.setStyleSheet("border: 1px solid transparent;")
else:
self._is_valid_input = False
if self.isEnabled():
self.setStyleSheet("border: 1px solid red;")
else:
self.setStyleSheet("border: 1px solid transparent;")
@Slot(str)
def select_x_axis(self, x_axis: str):
"""Slot to update the x axis.
@@ -154,7 +128,7 @@ class DapComboBox(BECWidget, QComboBox):
x_axis(str): X axis.
"""
self.x_axis = x_axis
self._on_text_changed(self.currentText())
self._update_current_fit(self.fit_model_combobox.currentText())
@Slot(str)
def select_y_axis(self, y_axis: str):
@@ -164,26 +138,25 @@ class DapComboBox(BECWidget, QComboBox):
y_axis(str): Y axis.
"""
self.y_axis = y_axis
self._on_text_changed(self.currentText())
self._update_current_fit(self.fit_model_combobox.currentText())
@Slot(str)
def select_fit_model(self, fit_name: str | None):
"""Slot to update the fit model.
Args:
fit_name(str): Fit model name.
default_device(str): Default device name.
"""
if not self._validate_dap_model(fit_name):
raise ValueError(f"Fit {fit_name} is not valid.")
self.setCurrentText(fit_name)
self.fit_model_combobox.setCurrentText(fit_name)
def populate_fit_model_combobox(self):
"""Populate the fit_model_combobox with the devices."""
# pylint: disable=protected-access
available_plugins = getattr(getattr(self.client, "dap", None), "_available_dap_plugins", {})
self.available_models = [model for model in available_plugins.keys()]
self.clear()
self.addItems(self.available_models)
self.available_models = [model for model in self.client.dap._available_dap_plugins.keys()]
self.fit_model_combobox.clear()
self.fit_model_combobox.addItems(self.available_models)
def _validate_dap_model(self, model: str | None) -> bool:
"""Validate the DAP model.
@@ -193,23 +166,23 @@ class DapComboBox(BECWidget, QComboBox):
"""
if model is None:
return False
return model in self.available_models
@property
def is_valid_input(self) -> bool:
"""Whether the current text matches an available DAP model."""
return self._is_valid_input
if model not in self.available_models:
return False
return True
if __name__ == "__main__": # pragma: no cover
import sys
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import apply_theme
app = QApplication(sys.argv)
app = QApplication([])
apply_theme("dark")
dialog = DapComboBox()
dialog.show()
sys.exit(app.exec_())
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
layout.addWidget(DapComboBox())
widget.show()
app.exec_()

View File

@@ -1778,7 +1778,7 @@ class Waveform(PlotBase):
if parent_curve is None:
logger.warning(
f"No device curve found for DAP curve '{dap_curve.name()}'!"
) # TODO triggered when DAP curve is removed from the curve dialog, why?
) # TODO triggerd when DAP curve is removed from the curve dialog, why?
continue
x_data, y_data = parent_curve.get_data()

View File

@@ -276,7 +276,7 @@ class Ring(BECConnector, QWidget):
for obj in dev_obj._info["signals"].values()
if obj["kind_str"] == "hinted"
and obj["signal_class"]
not in ["ProgressSignal", "AsyncSignal", "AsyncMultiSignal", "DynamicSignal"]
not in ["ProgressSignal", "AyncSignal", "AsyncMultiSignal", "DynamicSignal"]
]
normal_signals = [

View File

@@ -42,6 +42,9 @@ from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.expe
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_selection import (
ExperimentSelection,
)
from bec_widgets.widgets.services.bec_messaging_config.bec_messaging_config_widget import (
BECMessagingConfigWidget,
)
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QToolBar
@@ -104,7 +107,8 @@ class OverviewWidget(QGroupBox):
content_layout = QVBoxLayout(content)
content.setFrameShape(QFrame.Shape.StyledPanel)
content.setFrameShadow(QFrame.Shadow.Raised)
content.setStyleSheet("""
content.setStyleSheet(
"""
QFrame
{
border: 1px solid #cccccc;
@@ -113,7 +117,8 @@ class OverviewWidget(QGroupBox):
{
border: none;
}
""")
"""
)
content_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
content.setFixedSize(400, 280)
@@ -299,6 +304,11 @@ class BECAtlasAdminView(BECWidget, QWidget):
self.experiment_selection.setVisible(False)
self.stacked_layout.addWidget(self.experiment_selection)
# Messaging Services widget
self.messaging_config_widget = BECMessagingConfigWidget(parent=self)
self.messaging_config_widget.setVisible(False)
self.stacked_layout.addWidget(self.messaging_config_widget)
# Connect signals
self.overview_widget.login_requested.connect(self._on_login_requested)
self.overview_widget.change_experiment_requested.connect(
@@ -392,6 +402,7 @@ class BECAtlasAdminView(BECWidget, QWidget):
"""Show the overview panel."""
self.overview_widget.setVisible(True)
self.experiment_selection.setVisible(False)
self.messaging_config_widget.setVisible(False)
self.stacked_layout.setCurrentWidget(self.overview_widget)
def _on_experiment_selection_selected(self):
@@ -401,12 +412,20 @@ class BECAtlasAdminView(BECWidget, QWidget):
return
self.overview_widget.setVisible(False)
self.experiment_selection.setVisible(True)
self.messaging_config_widget.setVisible(False)
self.stacked_layout.setCurrentWidget(self.experiment_selection)
def _on_messaging_services_selected(self):
"""Show the messaging services panel."""
logger.info("Messaging services panel is not implemented yet.")
return
if not self._authenticated:
logger.warning("Attempted to access messaging services without authentication.")
return
self.overview_widget.setVisible(False)
self.experiment_selection.setVisible(False)
self.messaging_config_widget.setVisible(True)
if self._current_deployment_info is not None:
self.messaging_config_widget.populate_from_deployment(self._current_deployment_info)
self.stacked_layout.setCurrentWidget(self.messaging_config_widget)
########################
## Internal slots
@@ -446,6 +465,7 @@ class BECAtlasAdminView(BECWidget, QWidget):
atlas_url=self._atlas_url,
)
self.atlas_http_service._set_current_deployment_info(deployment)
self.messaging_config_widget.populate_from_deployment(deployment)
def _fetch_available_experiments(self):
"""Fetch the list of available experiments for the authenticated user."""
@@ -501,9 +521,7 @@ class BECAtlasAdminView(BECWidget, QWidget):
if authenticated:
self.toolbar.components.get_action("experiment_selection").action.setEnabled(True)
self.toolbar.components.get_action("messaging_services").action.setEnabled(
False
) # TODO activate once messaging is added
self.toolbar.components.get_action("messaging_services").action.setEnabled(True)
self.toolbar.components.get_action("logout").action.setEnabled(True)
self._fetch_available_experiments() # Fetch experiments upon successful authentication
self._atlas_info_widget.set_logged_in(info.email)

View File

@@ -0,0 +1,315 @@
"""Module for the BEC messaging configuration widget."""
from __future__ import annotations
import json
from qtpy.QtCore import Qt, QTimer, Signal # type: ignore[attr-defined]
from qtpy.QtWidgets import (
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSplitter,
QTabWidget,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.services.bec_messaging_config.service_cards import (
CardType,
ScopeListWidget,
card_from_service,
make_card,
)
from bec_widgets.widgets.services.bec_messaging_config.service_scope_event_table import (
ServiceScopeEventTableWidget,
)
class ServiceConfigPanel(QWidget):
"""Panel that manages global and local service scopes for one service type.
Args:
card_type (CardType): The service type used when adding new scope cards.
parent (QWidget | None): The parent widget.
"""
config_changed = Signal()
def __init__(self, card_type: CardType, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._card_type: CardType = card_type
root = QVBoxLayout(self)
root.setContentsMargins(12, 12, 12, 12)
root.setSpacing(0)
splitter = QSplitter(Qt.Orientation.Vertical)
# ── Local settings box ────────────────────────────────────────────
self._local_box = QGroupBox("Current Experiment")
self._local_list = ScopeListWidget()
self._local_list.cards_changed.connect(self.config_changed)
self._local_add_btn = QPushButton("+ Add")
self._local_add_btn.setFixedWidth(120)
self._local_add_btn.clicked.connect(
lambda: self._local_list.add_card(make_card(self._card_type))
)
local_layout = QVBoxLayout(self._local_box)
local_layout.setContentsMargins(16, 16, 16, 16)
local_layout.setSpacing(12)
local_layout.addWidget(self._local_add_btn, 0, Qt.AlignmentFlag.AlignRight)
local_layout.addWidget(self._local_list, 1)
splitter.addWidget(self._local_box)
# ── Global settings box ───────────────────────────────────────────
self._global_box = QGroupBox("All Experiments")
self._global_list = ScopeListWidget()
self._global_list.cards_changed.connect(self.config_changed)
self._global_add_btn = QPushButton("+ Add")
self._global_add_btn.setFixedWidth(120)
self._global_add_btn.clicked.connect(
lambda: self._global_list.add_card(make_card(self._card_type))
)
global_layout = QVBoxLayout(self._global_box)
global_layout.setContentsMargins(16, 16, 16, 16)
global_layout.setSpacing(12)
global_layout.addWidget(self._global_add_btn, 0, Qt.AlignmentFlag.AlignRight)
global_layout.addWidget(self._global_list, 1)
splitter.addWidget(self._global_box)
splitter.setSizes([300, 300])
root.addWidget(splitter, 1)
# ------------------------------------------------------------------
def load_services(self, deployment_services: list, session_services: list) -> None:
"""Populate both lists with services matching the panel service type."""
self._clear_list(self._global_list)
self._clear_list(self._local_list)
for info in deployment_services:
if getattr(info, "service_type", None) == self._card_type:
self._global_list.add_card(card_from_service(info))
for info in session_services:
if getattr(info, "service_type", None) == self._card_type:
self._local_list.add_card(card_from_service(info))
@staticmethod
def _clear_list(list_widget: ScopeListWidget) -> None:
"""Remove all cards from *list_widget*."""
list_widget.clear_cards()
# ------------------------------------------------------------------
def get_data(self) -> dict:
"""Collect all card data from both the deployment and session lists."""
return {
"deployment": self._collect(self._global_list),
"session": self._collect(self._local_list),
}
@staticmethod
def _collect(list_widget: ScopeListWidget) -> list[dict]:
return [card.get_data() for card in list_widget.cards()]
class BECMessagingConfigWidget(QWidget):
"""Widget to configure SciLog, Signal, and MS Teams messaging services."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("BEC Messaging Configuration")
self.setMinimumSize(540, 500)
root = QVBoxLayout(self)
root.setContentsMargins(16, 16, 16, 16)
root.setSpacing(12)
content_splitter = QSplitter(Qt.Orientation.Horizontal)
# ── Tab widget ────────────────────────────────────────────────────
self._tabs = QTabWidget()
self._scilog_panel = ServiceConfigPanel("scilog")
self._signal_panel = ServiceConfigPanel("signal")
self._teams_panel = ServiceConfigPanel("teams")
for panel in (self._scilog_panel, self._signal_panel, self._teams_panel):
panel.config_changed.connect(self._refresh_scope_event_table)
self._tabs.addTab(self._scilog_panel, "SciLog")
self._tabs.addTab(self._signal_panel, "Signal")
self._tabs.addTab(self._teams_panel, "MS Teams")
content_splitter.addWidget(self._tabs)
self._scope_event_table = ServiceScopeEventTableWidget(self)
content_splitter.addWidget(self._scope_event_table)
content_splitter.setStretchFactor(0, 3)
content_splitter.setStretchFactor(1, 2)
root.addWidget(content_splitter, 1)
# ── Bottom action bar ─────────────────────────────────────────────
bottom_row = QHBoxLayout()
bottom_row.setSpacing(12)
self._status_label = QLabel("")
self._status_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
bottom_row.addWidget(self._status_label, 1)
save_btn = QPushButton("Save && Apply")
save_btn.setDefault(True)
save_btn.clicked.connect(self._mock_save_to_atlas_api)
bottom_row.addWidget(save_btn)
root.addLayout(bottom_row)
# ------------------------------------------------------------------
# Initialisation from backend message
# ------------------------------------------------------------------
def populate_from_deployment(self, msg: DeploymentInfoMessage) -> None:
"""Populate all panels from a deployment info message.
Args:
msg (DeploymentInfoMessage): Deployment information containing deployment and session services.
"""
deployment_services = list(msg.messaging_services)
session_services = (
list(msg.active_session.messaging_services) if msg.active_session is not None else []
)
self._scilog_panel.load_services(deployment_services, session_services)
self._signal_panel.load_services(deployment_services, session_services)
self._teams_panel.load_services(deployment_services, session_services)
self._refresh_scope_event_table()
# ------------------------------------------------------------------
# Dummy REST methods (replace with real requests calls later)
# ------------------------------------------------------------------
def _build_payload(self) -> dict:
"""Collect the current UI state as a serializable dictionary."""
return {
"scilog": self._scilog_panel.get_data(),
"signal": self._signal_panel.get_data(),
"teams": self._teams_panel.get_data(),
"event_subscriptions": self._scope_event_table.get_data(),
}
def _refresh_scope_event_table(self) -> None:
"""Refresh the event subscription table from the current service cards."""
self._scope_event_table.set_services(self._collect_services_for_event_table())
def _collect_services_for_event_table(self) -> list[dict]:
"""Collect all configured services for the event subscription table."""
service_rows: list[dict] = []
for panel in (self._scilog_panel, self._signal_panel, self._teams_panel):
panel_data = panel.get_data()
for source_name in ("deployment", "session"):
for service in panel_data[source_name]:
service_rows.append({**service, "source": source_name})
return service_rows
def _mock_save_to_atlas_api(self) -> None:
"""Simulate saving the current configuration to Atlas."""
payload = self._build_payload()
print("" * 60)
print("[BECMessagingConfigWidget] _mock_save_to_atlas_api payload:")
print(json.dumps(payload, indent=2))
print("" * 60)
self._set_status("✅ Saved!", timeout_ms=4000)
# ------------------------------------------------------------------
# Status bar helper
# ------------------------------------------------------------------
def _set_status(self, message: str, *, timeout_ms: int = 0) -> None:
"""Show a status message and optionally clear it after a timeout.
Args:
message (str): The message to display in the status label.
timeout_ms (int): Time in milliseconds before clearing the message.
"""
self._status_label.setText(message)
if timeout_ms > 0:
QTimer.singleShot(timeout_ms, lambda: self._status_label.setText(""))
if __name__ == "__main__": # pragma: no cover
import sys
from bec_lib.messages import (
DeploymentInfoMessage,
MessagingConfig,
MessagingServiceScopeConfig,
SciLogServiceInfo,
SessionInfoMessage,
SignalServiceInfo,
TeamsServiceInfo,
)
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("dark")
# ── Build a realistic mock DeploymentInfoMessage ──────────────────
mock_deployment = DeploymentInfoMessage(
deployment_id="dep-0001",
name="mockup-beamline",
messaging_config=MessagingConfig(
signal=MessagingServiceScopeConfig(enabled=True),
teams=MessagingServiceScopeConfig(enabled=True),
scilog=MessagingServiceScopeConfig(enabled=True),
),
messaging_services=[
SciLogServiceInfo(
id="sl-global-1",
scope="beamline",
enabled=True,
name="Beamline Log",
logbook_id="lb-99001",
),
TeamsServiceInfo(
id="teams-global-1",
scope="beamline",
enabled=True,
name="BEC Channel",
workflow_webhook_url="https://outlook.office.com/webhook/…",
),
SignalServiceInfo(
id="signal-global-1",
scope="beamline",
enabled=False,
name=None,
group_id=None,
group_link=None,
),
],
active_session=SessionInfoMessage(
name="session-2026-03-07",
messaging_services=[
SciLogServiceInfo(
id="sl-local-1",
scope="experiment",
enabled=True,
name="My Notebook",
logbook_id="lb-12345",
),
SignalServiceInfo(
id="signal-local-1",
scope="experiment",
enabled=True,
name="Lab Signal Group",
group_id="grp-8a3f291c",
group_link="https://signal.group/#grp-8a3f291c",
),
],
),
)
widget = BECMessagingConfigWidget()
widget.populate_from_deployment(mock_deployment)
widget.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,429 @@
"""Module for service scope cards used by the messaging configuration widget."""
from __future__ import annotations
import uuid
from enum import IntEnum
from typing import TYPE_CHECKING, Literal, Type
from bec_qthemes import material_icon
from qtpy.QtCore import QRegularExpression, Qt, QTimer, Signal # type: ignore[attr-defined]
from qtpy.QtGui import QRegularExpressionValidator
from qtpy.QtWidgets import (
QCheckBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QSizePolicy,
QSpacerItem,
QStackedLayout,
QToolButton,
QVBoxLayout,
QWidget,
)
if TYPE_CHECKING: # pragma: no cover
from bec_lib import messages
CardType = Literal["scilog", "signal", "teams"]
class ScopeListWidget(QScrollArea):
"""A scrollable list that stacks scope cards neatly at the top."""
cards_changed = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWidgetResizable(True)
self.setFrameShape(QFrame.Shape.NoFrame)
self._container = QWidget()
self._layout = QVBoxLayout(self._container)
self._layout.setContentsMargins(4, 8, 4, 8)
self._layout.setSpacing(16)
self._spacer = QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self._layout.addSpacerItem(self._spacer)
self.setWidget(self._container)
def add_card(self, card: BaseScopeCard) -> None:
"""Insert a card above the trailing spacer.
Args:
card (BaseScopeCard): The card widget to add to the list.
"""
idx = self._layout.count() - 1
self._layout.insertWidget(idx, card)
card.delete_requested.connect(lambda: self._remove_card(card))
card.delete_requested.connect(self.cards_changed)
card.data_changed.connect(self.cards_changed)
self.cards_changed.emit()
def clear_cards(self) -> None:
"""Remove all cards without touching the trailing spacer."""
for index in range(self._layout.count() - 2, -1, -1):
item = self._layout.itemAt(index)
if item is None:
continue
card = item.widget()
if isinstance(card, BaseScopeCard):
self._layout.removeWidget(card)
card.deleteLater()
self.cards_changed.emit()
def cards(self) -> list[BaseScopeCard]:
"""Return the cards currently stored in the list."""
results: list[BaseScopeCard] = []
for index in range(self._layout.count()):
item = self._layout.itemAt(index)
if item is None:
continue
card = item.widget()
if isinstance(card, BaseScopeCard):
results.append(card)
return results
def _remove_card(self, card: BaseScopeCard) -> None:
self._layout.removeWidget(card)
card.deleteLater()
self.cards_changed.emit()
class BaseScopeCard(QFrame):
"""Base card with shared identity, scope, and enabled fields."""
delete_requested = Signal()
data_changed = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._id: str = str(uuid.uuid4())
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setFrameShadow(QFrame.Shadow.Raised)
self.setStyleSheet(
"BaseScopeCard {"
" border: 1px solid palette(mid);"
" border-radius: 6px;"
" background: palette(base);"
"}"
)
root = QVBoxLayout(self)
root.setContentsMargins(20, 16, 20, 20)
root.setSpacing(14)
header_row = QHBoxLayout()
header_row.setSpacing(10)
self.enabled_checkbox = QCheckBox("Enabled")
self.enabled_checkbox.setChecked(True)
self.enabled_checkbox.toggled.connect(self.data_changed)
header_row.addWidget(self.enabled_checkbox)
header_row.addStretch(1)
self._delete_btn = QToolButton()
delete_icon = material_icon(
"delete", size=(25, 25), convert_to_pixmap=False, filled=False, color="#CC181E"
)
self._delete_btn.setToolTip("Delete this scope configuration")
self._delete_btn.setIcon(delete_icon)
self._delete_btn.clicked.connect(self.delete_requested)
header_row.addWidget(self._delete_btn)
root.addLayout(header_row)
identity_row = QHBoxLayout()
identity_row.setSpacing(16)
scope_col = QVBoxLayout()
scope_col.setSpacing(4)
scope_col.addWidget(QLabel("Scope"))
self.scope_edit = QLineEdit()
self.scope_edit.setPlaceholderText("e.g. user, admin")
self.scope_edit.textChanged.connect(self.data_changed)
scope_col.addWidget(self.scope_edit)
identity_row.addLayout(scope_col, 1)
name_col = QVBoxLayout()
name_col.setSpacing(4)
name_col.addWidget(QLabel("Name (optional)"))
self.name_edit = QLineEdit()
self.name_edit.setPlaceholderText("display name")
self.name_edit.textChanged.connect(self.data_changed)
name_col.addWidget(self.name_edit)
identity_row.addLayout(name_col, 1)
root.addLayout(identity_row)
self.content_layout = QVBoxLayout()
self.content_layout.setContentsMargins(0, 0, 0, 0)
self.content_layout.setSpacing(12)
root.addLayout(self.content_layout)
def get_data(self) -> dict:
"""Return the common payload for a messaging service card."""
return {
"id": self._id,
"scope": self.scope_edit.text(),
"enabled": self.enabled_checkbox.isChecked(),
"name": self.name_edit.text() or None,
}
def set_data(self, info: messages.MessagingService) -> None: # type: ignore[name-defined]
"""Populate the shared card fields from a messaging service.
Args:
info (messages.MessagingService): The service object used to populate the card.
"""
self._id = info.id
self.scope_edit.setText(info.scope)
self.enabled_checkbox.setChecked(info.enabled)
self.name_edit.setText(info.name or "")
class SciLogScopeCard(BaseScopeCard):
"""Card used to configure SciLog service settings."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
col = QVBoxLayout()
col.setSpacing(4)
col.addWidget(QLabel("Logbook ID"))
self.logbook_id_edit = QLineEdit()
self.logbook_id_edit.setPlaceholderText("e.g. lb-12345")
self.logbook_id_edit.textChanged.connect(self.data_changed)
col.addWidget(self.logbook_id_edit)
self.content_layout.addLayout(col)
def get_data(self) -> dict:
"""Return the SciLog-specific payload for this card."""
data = super().get_data()
data["service_type"] = "scilog"
data["logbook_id"] = self.logbook_id_edit.text()
return data
def set_data(self, info: messages.SciLogServiceInfo) -> None: # type: ignore[override]
"""Populate the card from SciLog service information.
Args:
info (messages.SciLogServiceInfo): The SciLog service object used to populate the card.
"""
super().set_data(info)
self.logbook_id_edit.setText(info.logbook_id)
class TeamsScopeCard(BaseScopeCard):
"""Card used to configure MS Teams service settings."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
fields_row = QHBoxLayout()
fields_row.setSpacing(16)
col = QVBoxLayout()
col.setSpacing(4)
col.addWidget(QLabel("Workflow Webhook URL"))
self.workflow_webhook_url_edit = edit = QLineEdit(parent=self)
edit.setPlaceholderText("e.g. https://outlook.office.com/webhook/…")
edit.textChanged.connect(self.data_changed)
col.addWidget(edit)
fields_row.addLayout(col, 1)
self.content_layout.addLayout(fields_row)
def get_data(self) -> dict:
"""Return the MS Teams-specific payload for this card."""
data = super().get_data()
data["service_type"] = "teams"
data["workflow_webhook_url"] = self.workflow_webhook_url_edit.text()
return data
def set_data(self, info: messages.TeamsServiceInfo) -> None: # type: ignore[override]
"""Populate the card from MS Teams service information.
Args:
info (messages.TeamsServiceInfo): The MS Teams service object used to populate the card.
"""
super().set_data(info)
self.workflow_webhook_url_edit.setText(info.workflow_webhook_url)
class _SignalState(IntEnum):
UNCONFIGURED = 0
PENDING = 1
CONFIGURED = 2
class SignalScopeCard(BaseScopeCard):
"""Card used to configure Signal service settings and linking state."""
_MOCK_GROUP_ID = "grp-8a3f291c"
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._state = _SignalState.UNCONFIGURED
self._mock_group_id: str = ""
self._mock_group_link: str = ""
stacked_container = QWidget()
self._stacked = QStackedLayout(stacked_container)
self._stacked.setContentsMargins(0, 0, 0, 0)
self.content_layout.addWidget(stacked_container)
self._build_unconfigured_page()
self._build_pending_page()
self._build_configured_page()
self._stacked.setCurrentIndex(_SignalState.UNCONFIGURED)
def _build_unconfigured_page(self) -> None:
page = QWidget()
row = QHBoxLayout(page)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(6)
phone_col = QVBoxLayout()
phone_col.setSpacing(4)
phone_col.addWidget(QLabel("Phone Number"))
self._phone_edit = QLineEdit()
self._phone_edit.setValidator(
QRegularExpressionValidator(QRegularExpression(r"^\+\S*$"), self._phone_edit)
)
self._phone_edit.setPlaceholderText("+41791234567")
self._phone_edit.textChanged.connect(self.data_changed)
phone_col.addWidget(self._phone_edit)
row.addLayout(phone_col, 1)
start_linking_btn = QPushButton("Start Linking")
start_linking_btn.setFixedWidth(100)
start_linking_btn.clicked.connect(self._on_ping_clicked)
row.addWidget(start_linking_btn, 0, Qt.AlignmentFlag.AlignBottom)
self._stacked.addWidget(page)
def _build_pending_page(self) -> None:
page = QWidget()
row = QHBoxLayout(page)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(6)
waiting_lbl = QLabel("⏳ Waiting for you to reply on Signal…")
waiting_lbl.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
row.addWidget(waiting_lbl)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self._on_cancel_clicked)
row.addWidget(cancel_btn)
self._stacked.addWidget(page)
def _build_configured_page(self) -> None:
page = QWidget()
row = QHBoxLayout(page)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(6)
self._linked_lbl = QLabel("🟢 Linked (Group ID: —)")
self._linked_lbl.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
row.addWidget(self._linked_lbl)
unlink_btn = QPushButton("Unlink")
unlink_btn.clicked.connect(self._on_unlink_clicked)
row.addWidget(unlink_btn)
self._stacked.addWidget(page)
def _on_ping_clicked(self) -> None:
self._state = _SignalState.PENDING
self._stacked.setCurrentIndex(_SignalState.PENDING)
QTimer.singleShot(3000, self._mock_backend_confirmation)
def _mock_backend_confirmation(self) -> None:
if self._state != _SignalState.PENDING:
return
self._mock_group_id = self._MOCK_GROUP_ID
self._mock_group_link = f"https://signal.group/#{self._mock_group_id}"
self._linked_lbl.setText(f"🟢 Linked (Group ID: {self._mock_group_id})")
self._state = _SignalState.CONFIGURED
self._stacked.setCurrentIndex(_SignalState.CONFIGURED)
self.data_changed.emit()
def _on_cancel_clicked(self) -> None:
self._state = _SignalState.UNCONFIGURED
self._stacked.setCurrentIndex(_SignalState.UNCONFIGURED)
self.data_changed.emit()
def _on_unlink_clicked(self) -> None:
self._mock_group_id = ""
self._mock_group_link = ""
self._state = _SignalState.UNCONFIGURED
self._stacked.setCurrentIndex(_SignalState.UNCONFIGURED)
self.data_changed.emit()
def get_data(self) -> dict:
"""Return the Signal-specific payload for this card."""
data = super().get_data()
data["service_type"] = "signal"
configured = self._state == _SignalState.CONFIGURED
data["group_id"] = self._mock_group_id if configured else None
data["group_link"] = self._mock_group_link if configured else None
return data
def set_data(self, info: messages.SignalServiceInfo) -> None: # type: ignore[override]
"""Populate the card from Signal service information.
Args:
info (messages.SignalServiceInfo): The Signal service object used to populate the card.
"""
super().set_data(info)
if info.group_id:
self._mock_group_id = info.group_id
self._mock_group_link = info.group_link or ""
self._linked_lbl.setText(f"🟢 Linked (Group ID: {self._mock_group_id})")
self._state = _SignalState.CONFIGURED
self._stacked.setCurrentIndex(_SignalState.CONFIGURED)
return
self._mock_group_id = ""
self._mock_group_link = ""
self._state = _SignalState.UNCONFIGURED
self._stacked.setCurrentIndex(_SignalState.UNCONFIGURED)
_CARD_CLASSES: dict[CardType, Type[BaseScopeCard]] = {
"scilog": SciLogScopeCard,
"signal": SignalScopeCard,
"teams": TeamsScopeCard,
}
def make_card(card_type: CardType) -> BaseScopeCard:
"""Create a new service card for the requested card type.
Args:
card_type (CardType): The service type for the card to create.
"""
return _CARD_CLASSES[card_type]()
def card_from_service(info: object) -> BaseScopeCard:
"""Create and populate a card from a messaging service object.
Args:
info (object): A messaging service object with a ``service_type`` attribute.
"""
service_type: str = getattr(info, "service_type", "")
card_class = _CARD_CLASSES.get(service_type) # type: ignore[arg-type]
if card_class is None:
raise ValueError(f"Unknown service_type: {service_type!r}")
card = card_class()
card.set_data(info) # type: ignore[arg-type]
return card

View File

@@ -0,0 +1,125 @@
"""Module for the service scope event subscription table widget."""
from __future__ import annotations
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QAbstractItemView,
QCheckBox,
QHBoxLayout,
QHeaderView,
QSizePolicy,
QTableWidget,
QVBoxLayout,
QWidget,
)
class ServiceScopeEventTableWidget(QWidget):
"""Widget that manages per-scope event subscriptions for messaging services."""
EVENT_NAMES = ("new_scan", "scan_finished", "alarm")
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._services: list[dict] = []
self._subscriptions: dict[str, dict[str, bool]] = {}
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
self._table = QTableWidget(len(self.EVENT_NAMES), 0, self)
self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self._table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self._table.setAlternatingRowColors(True)
self._table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._table.setVerticalHeaderLabels(list(self.EVENT_NAMES))
self._table.horizontalHeader().setStretchLastSection(True)
header = self._table.horizontalHeader()
header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self._table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
root.addWidget(self._table, 1)
def set_services(self, services: list[dict]) -> None:
"""Update the table rows to match the current services.
Args:
services (list[dict]): Service dictionaries collected from the service configuration panels.
"""
self._services = [dict(service) for service in services]
known_ids = {str(service.get("id", "")) for service in self._services if service.get("id")}
self._subscriptions = {
service_id: subscriptions
for service_id, subscriptions in self._subscriptions.items()
if service_id in known_ids
}
self._table.clearContents()
self._table.setRowCount(len(self.EVENT_NAMES))
self._table.setColumnCount(len(self._services))
self._table.setHorizontalHeaderLabels(
[self._format_service_label(service) for service in self._services]
)
for column, service in enumerate(self._services):
service_id = str(service.get("id", ""))
event_states = self._subscriptions.setdefault(
service_id, {event_name: False for event_name in self.EVENT_NAMES}
)
for row, event_name in enumerate(self.EVENT_NAMES):
self._table.setCellWidget(
row,
column,
self._make_checkbox_cell(
service_id, event_name, event_states.get(event_name, False)
),
)
def get_data(self) -> list[dict]:
"""Return the event subscriptions for the current services."""
results: list[dict] = []
for service in self._services:
service_id = str(service.get("id", ""))
results.append(
{
"id": service_id,
"source": service.get("source"),
"service_type": service.get("service_type"),
"scope": service.get("scope"),
"events": dict(
self._subscriptions.get(
service_id, {event_name: False for event_name in self.EVENT_NAMES}
)
),
}
)
return results
def _format_service_label(self, service: dict) -> str:
service_name = str(service.get("service_type", ""))
scope_name = str(service.get("scope", ""))
source_name = str(service.get("source", ""))
return f"{service_name}\n{scope_name}\n({source_name})"
def _make_checkbox_cell(self, service_id: str, event_name: str, checked: bool) -> QWidget:
checkbox = QCheckBox()
checkbox.setChecked(checked)
checkbox.toggled.connect(
lambda state, current_service_id=service_id, current_event_name=event_name: self._set_event_state(
current_service_id, current_event_name, state
)
)
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(checkbox)
return container
def _set_event_state(self, service_id: str, event_name: str, checked: bool) -> None:
self._subscriptions.setdefault(service_id, {})[event_name] = checked

View File

@@ -88,7 +88,7 @@ class DeviceBrowser(BECWidget, QWidget):
self.setLayout(layout)
def init_warning_label(self):
self.ui.scan_running_warning.setText("Warning: editing disabled while scan is running!")
self.ui.scan_running_warning.setText("Warning: editing diabled while scan is running!")
self.ui.scan_running_warning.setStyleSheet(
"background-color: #fcba03; color: rgb(0, 0, 0);"
)

View File

@@ -160,8 +160,8 @@ class ScanHistoryMetadataViewer(BECWidget, QtWidgets.QGroupBox):
Clear the view by resetting the labels and values.
"""
layout = self.layout()
layout_counts = layout.count()
for i in range(layout_counts):
lauout_counts = layout.count()
for i in range(lauout_counts):
item = layout.itemAt(i)
if item.widget():
item.widget().close()

View File

@@ -305,7 +305,7 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
def remove_scan(self, index: int):
"""
Remove a scan entry from the tree widget.
We support negative indexing where -1, -2, etc.
We supoprt negative indexing where -1, -2, etc.
Args:
index (int): The index of the scan entry to remove.

View File

@@ -31,7 +31,7 @@ api_reference/api_reference.md
## Introduction
An introduction into the single-responsibility principle and the modular design of BEC Widgets.
An introduction into the single-resposibility principle and the modular design of BEC Widgets.
```
```{grid-item-card}

View File

@@ -19,7 +19,7 @@ cd bec_widgets
```
**Install in Editable Mode**:
Please install the package in editable mode into your BEC Python environment.
Please install the package in editable mode into your BEC Python environemnt.
```bash
pip install -e '.[dev,pyside6]'
```

View File

@@ -16,7 +16,7 @@ that the widgets are discoverable.
- make sure that the widget class inherits from both `BECWidget` as well as `QWidget` or a subclass
of it, such as `QComboBox` or `QLineEdit`.
- make sure it initialises each of these superclasses in its `__init__()` method, and passes the
`parent` keyword argument on to `QWidget.__init__()`.
`parent` keyword argumment on to `QWidget.__init__()`.
- add `PLUGIN = True` as a class variable to the widget class
- add `USER_ACCESS = [...]`, including any methods and properties which should be accessible in the
client to the list, as strings.

View File

@@ -17,7 +17,7 @@
````
````{tab} Examples - CLI
In the following examples, we will use `BECIPythonClient` as the main object to interact with the `BECDockArea`. These tutorials focus on how to work with the `BECDockArea` framework, such as adding and removing docks, saving and restoring layouts, and managing the docked widgets. By default the `BECDockArea` is referred to as `gui` in `BECIPythonClient`. For more detailed examples of each individual component, please refer to the example sections of each individual [`widget`](user.widgets).
In the following examples, we will use `BECIPythonClient` as the main object to interact with the `BECDockArea`. These tutorials focus on how to work with the `BECDockArea` framework, such as adding and removing docks, saving and restoring layouts, and managing the docked widgets. By default the `BECDockArea` is refered as `gui` in `BECIPythonClient`. For more detailed examples of each individual component, please refer to the example sections of each individual [`widget`](user.widgets).
## Example 1 - Adding Docks to BECDockArea
@@ -62,7 +62,7 @@ dock_area.waveform_dock
dock_area.motor_dock
dock_area.image_dock
# If objects were closed, we will keep a reference that will indicate that the dock was deleted
# If objects were closed, we will keep a refernce that will indicate that the dock was deleted
# Try closing the window with the dock_area via mouse click on x
dock_area

View File

@@ -79,7 +79,7 @@ if __name__ == "__main__":
````
````{tab} Examples - BEC designer
````{tab} Examples - BEC desginer
The various properties can also be set when the SignalLabel widget is added to a UI in BEC designer:
```{figure} ./designer_screenshot.png

View File

@@ -215,7 +215,7 @@ Display custom text or HTML content.
Display website content.
```
```{grid-item-card} Toggle Widget
```{grid-item-card} Toogle Widget
:link: user.widgets.toggle
:link-type: ref
:img-top: /assets/widget_screenshots/toggle.png
@@ -244,7 +244,7 @@ Modern progress bar for BEC.
:link-type: ref
:img-top: /assets/widget_screenshots/position_indicator.png
Display position of motor within its limits.
Display position of motor withing its limits.
```
```{grid-item-card} LMFit Dialog

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "3.3.2"
version = "3.3.0"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [

View File

@@ -32,8 +32,8 @@ def threads_check_fixture(threads_check):
@pytest.fixture
def gui_id():
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturb"""
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturb
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturbate"""
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturbate
@pytest.fixture(scope="function")

View File

@@ -22,7 +22,7 @@ from bec_widgets.cli.client_utils import BECGuiClient
@pytest.fixture(scope="module")
def gui_id():
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturb"""
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturbate"""
return f"figure_{random.randint(0,100)}"

View File

@@ -17,8 +17,6 @@ def dap_combobox(qtbot, mocked_client):
def test_dap_combobox_init(dap_combobox):
"""Test DapComboBox init."""
assert dap_combobox.fit_model_combobox is dap_combobox
assert dap_combobox.isEditable() is True
assert dap_combobox.fit_model_combobox.currentText() == "GaussianModel"
assert dap_combobox.available_models == ["GaussianModel", "LorentzModel", "SineModel"]
assert dap_combobox._validate_dap_model("GaussianModel") is True
@@ -32,7 +30,7 @@ def test_dap_combobox_set_axis(dap_combobox):
container = []
def my_callback(msg: str):
"""Callback function to store the messages."""
"""Calback function to store the messages."""
container.append(msg)
dap_combobox.x_axis_updated.connect(my_callback)
@@ -51,7 +49,7 @@ def test_dap_combobox_select_fit(dap_combobox):
container = []
def my_callback(msg: str):
"""Callback function to store the messages."""
"""Calback function to store the messages."""
container.append(msg)
dap_combobox.fit_model_updated.connect(my_callback)
@@ -66,32 +64,10 @@ def test_dap_combobox_currentTextchanged(dap_combobox):
container = []
def my_callback(msg: str):
"""Callback function to store the messages."""
"""Calback function to store the messages."""
container.append(msg)
assert dap_combobox.fit_model_combobox.currentText() == "GaussianModel"
dap_combobox.fit_model_updated.connect(my_callback)
dap_combobox.fit_model_combobox.setCurrentText("SineModel")
assert container[0] == "SineModel"
def test_dap_combobox_init_without_available_models(qtbot, mocked_client):
mocked_client.dap._available_dap_plugins = {}
widget = create_widget(qtbot, DapComboBox, client=mocked_client)
assert widget.available_models == []
assert widget.fit_model_combobox.count() == 0
assert widget.fit_model_combobox.currentText() == ""
def test_dap_combobox_invalid_manual_entry_highlighted(dap_combobox):
dap_combobox.setCurrentText("not-a-model")
assert dap_combobox.is_valid_input is False
assert "red" in dap_combobox.styleSheet()
dap_combobox.setCurrentText("GaussianModel")
assert dap_combobox.is_valid_input is True
assert "transparent" in dap_combobox.styleSheet()

View File

@@ -1422,7 +1422,7 @@ class TestDeviceConfigTemplate:
qtbot.waitExposed(template)
yield template
def test_device_config_template_default_init(
def test_device_config_teamplate_default_init(
self, device_config_template: DeviceConfigTemplate, qtbot
):
"""Test DeviceConfigTemplate default initialization."""

View File

@@ -113,7 +113,7 @@ def test_client_generator_with_black_formatting():
class _WidgetsEnumType(str, enum.Enum):
"""Enum for the available widgets, to be generated programmatically"""
"""Enum for the available widgets, to be generated programatically"""
...

View File

@@ -113,7 +113,7 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
)
def fill_components(components: dict[str, DynamicFormItem]):
def fill_commponents(components: dict[str, DynamicFormItem]):
components["sample_name"].setValue("test name")
components["str_optional"].setValue(None)
components["str_required"].setValue("something")
@@ -147,7 +147,7 @@ def test_griditems_are_correct_class(
def test_grid_to_dict(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]):
widget, components = metadata_widget = metadata_widget
fill_components(components)
fill_commponents(components)
assert widget._dict_from_grid() == TEST_DICT
assert widget.get_form_data() == TEST_DICT | {"extra_field": "extra_data"}
@@ -159,7 +159,7 @@ def test_validation(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormIt
widget._validity.compact_status.default_led[:114]
)
fill_components(components)
fill_commponents(components)
widget.validate_form()
assert widget._validity_message.text() == "No errors!"
@@ -178,7 +178,7 @@ def test_numbers_clipped_to_limits(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
widget, components = metadata_widget = metadata_widget
fill_components(components)
fill_commponents(components)
components["decimal_dp_limits_nodefault"].setValue(-56)
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(1.01)

View File

@@ -637,7 +637,7 @@ def test_fetch_scan_data_and_access(qtbot, mocked_client, monkeypatch):
wf._fetch_scan_data_and_access()
hist_mock.assert_called_once_with(-1)
# Check live mode
# Ckeck live mode
dummy_scan = create_dummy_scan_item()
wf.scan_item = dummy_scan
data_dict, access_key = wf._fetch_scan_data_and_access()