diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py index 0e5e3b77..9b6e9c4a 100644 --- a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py @@ -7,7 +7,7 @@ from typing import Any from bec_lib import bl_states from bec_lib.endpoints import MessageEndpoints from bec_qthemes import material_icon -from qtpy.QtCore import Qt, QTimer, Signal, Slot +from qtpy.QtCore import QEvent, Qt, QTimer, Signal, Slot from qtpy.QtGui import QColor, QPalette from qtpy.QtWidgets import ( QApplication, @@ -16,6 +16,7 @@ from qtpy.QtWidgets import ( QDialog, QDialogButtonBox, QFormLayout, + QGridLayout, QGroupBox, QHBoxLayout, QLabel, @@ -52,6 +53,8 @@ class BeamlineStatePill(BECWidget, QWidget): USER_ACCESS = ["state_name", "set_state_name", "remove", "attach", "detach", "screenshot"] state_changed = Signal(str, str, str) + update_requested = Signal(str, dict) + remove_requested = Signal(str) _STATUS_LABELS = { "valid": "VALID", @@ -81,14 +84,20 @@ class BeamlineStatePill(BECWidget, QWidget): ) self._state_name: str | None = None self._title: str | None = None + self._state_config: dict[str, Any] = {} self._status = "unknown" self._label = "No state information available." self._flash_active = False + self._expanded = False self._flash_timer = QTimer(self) self._flash_timer.setSingleShot(True) self._flash_timer.timeout.connect(self._clear_state_flash) + self._header = QWidget(self) + self._header.setObjectName("beamline_state_header") + self._header.setCursor(Qt.CursorShape.PointingHandCursor) + self._stripe = QWidget(self) self._stripe.setObjectName("beamline_state_stripe") self._stripe.setFixedWidth(4) @@ -108,6 +117,11 @@ class BeamlineStatePill(BECWidget, QWidget): self._detail_label.setObjectName("beamline_state_detail") self._detail_label.setTextFormat(Qt.TextFormat.PlainText) self._detail_label.setWordWrap(True) + self._expand_button = QToolButton(self) + self._expand_button.setObjectName("beamline_state_expand") + self._expand_button.setAutoRaise(True) + self._expand_button.setCursor(Qt.CursorShape.PointingHandCursor) + self._expand_button.clicked.connect(self._toggle_expanded) text_layout = QVBoxLayout() text_layout.setContentsMargins(0, 0, 0, 0) @@ -115,18 +129,128 @@ class BeamlineStatePill(BECWidget, QWidget): text_layout.addWidget(self._name_label) text_layout.addWidget(self._detail_label) - layout = QHBoxLayout(self) - layout.setContentsMargins(10, 8, 12, 8) - layout.setSpacing(10) - layout.addWidget(self._stripe) - layout.addWidget(self._icon_label) - layout.addLayout(text_layout, 1) - layout.addWidget(self._status_label, 0, Qt.AlignmentFlag.AlignRight) + header_layout = QHBoxLayout(self._header) + header_layout.setContentsMargins(10, 8, 12, 8) + header_layout.setSpacing(10) + header_layout.addWidget(self._stripe) + header_layout.addWidget(self._icon_label) + header_layout.addLayout(text_layout, 1) + header_layout.addWidget(self._status_label, 0, Qt.AlignmentFlag.AlignRight) + header_layout.addWidget(self._expand_button) + + self._settings = QWidget(self) + self._settings.setObjectName("beamline_state_settings") + self._settings.setVisible(False) + self._state_type_value = QLabel(self._settings) + self._name_value = QLabel(self._settings) + self._title_edit = QLineEdit(self._settings) + self._device_edit = DeviceComboBox(parent=self._settings, client=client) + self._signal_edit = SignalComboBox( + parent=self._settings, client=client, require_device=True + ) + self._low_limit_enabled, self._low_limit = self._create_optional_limit_row() + self._high_limit_enabled, self._high_limit = self._create_optional_limit_row() + self._tolerance = BECSpinBox(self._settings) + self._configure_settings_spinbox(self._tolerance) + self._device_edit.device_selected.connect(self._on_settings_device_selected) + self._device_edit.device_reset.connect(self._on_settings_device_reset) + self._low_limit_enabled.toggled.connect(self._low_limit.setEnabled) + self._high_limit_enabled.toggled.connect(self._high_limit.setEnabled) + + self._type_label = self._create_settings_label("Type") + self._name_settings_label = self._create_settings_label("Name") + self._title_label = self._create_settings_label("Title") + self._device_label = self._create_settings_label("Device") + self._signal_label = self._create_settings_label("Signal") + self._low_limit_label = self._create_settings_label("Low limit") + self._high_limit_label = self._create_settings_label("High limit") + self._tolerance_label = self._create_settings_label("Tolerance") + + button_layout = QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(8) + button_layout.addStretch(1) + self._update_button = QPushButton("Update", self._settings) + self._update_button.setIcon(material_icon("save", convert_to_pixmap=False)) + self._remove_button = QPushButton("Remove", self._settings) + self._remove_button.setObjectName("beamline_state_remove_button") + self._remove_button.setIcon(material_icon("delete", convert_to_pixmap=False)) + self._update_button.clicked.connect(self._emit_update_requested) + self._remove_button.clicked.connect(self._emit_remove_requested) + button_layout.addWidget(self._update_button) + button_layout.addWidget(self._remove_button) + + self._settings_grid = QGridLayout() + self._settings_grid.setContentsMargins(12, 8, 12, 8) + self._settings_grid.setHorizontalSpacing(10) + self._settings_grid.setVerticalSpacing(8) + self._settings_grid.addWidget(self._type_label, 0, 0) + self._settings_grid.addWidget(self._state_type_value, 0, 1) + self._settings_grid.addWidget(self._name_settings_label, 0, 2) + self._settings_grid.addWidget(self._name_value, 0, 3) + self._settings_grid.addWidget(self._title_label, 1, 0) + self._settings_grid.addWidget(self._title_edit, 1, 1, 1, 3) + self._settings_grid.addWidget(self._device_label, 2, 0) + self._settings_grid.addWidget(self._device_edit, 2, 1) + self._settings_grid.addWidget(self._signal_label, 2, 2) + self._settings_grid.addWidget(self._signal_edit, 2, 3) + self._settings_grid.addWidget(self._low_limit_label, 3, 0) + self._settings_grid.addWidget(self._low_limit.parentWidget(), 3, 1) + self._settings_grid.addWidget(self._high_limit_label, 3, 2) + self._settings_grid.addWidget(self._high_limit.parentWidget(), 3, 3) + self._settings_grid.addWidget(self._tolerance_label, 4, 0) + self._settings_grid.addWidget(self._tolerance, 4, 1) + self._settings_grid.addLayout(button_layout, 4, 2, 1, 2) + self._settings_grid.setColumnStretch(1, 1) + self._settings_grid.setColumnStretch(3, 1) + + self._limit_widgets = ( + self._low_limit_label, + self._low_limit.parentWidget(), + self._high_limit_label, + self._high_limit.parentWidget(), + self._tolerance_label, + self._tolerance, + ) + + settings_layout = QVBoxLayout(self._settings) + settings_layout.setContentsMargins(0, 0, 0, 0) + settings_layout.setSpacing(0) + settings_layout.addLayout(self._settings_grid) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self._header) + layout.addWidget(self._settings) self.setLayout(layout) + for widget in ( + self._header, + self._stripe, + self._icon_label, + self._name_label, + self._status_label, + self._detail_label, + ): + widget.installEventFilter(self) + self.setMinimumHeight(58) self.set_state_name(state_name, title=title) + def eventFilter(self, watched: object, event: QEvent) -> bool: # noqa: N802 + if event.type() == QEvent.Type.MouseButtonRelease and watched in { + self._header, + self._stripe, + self._icon_label, + self._name_label, + self._status_label, + self._detail_label, + }: + self._toggle_expanded() + return True + return super().eventFilter(watched, event) + @property def state_name(self) -> str | None: """Name of the BEC beamline state displayed by this pill.""" @@ -162,6 +286,11 @@ class BeamlineStatePill(BECWidget, QWidget): self.update_state, MessageEndpoints.beamline_state(self._state_name) ) + def set_state_config(self, state_config: dict[str, Any]) -> None: + """Set the editable BEC state configuration displayed by the expanded panel.""" + self._state_config = state_config + self._populate_settings() + def _refresh_latest_state(self) -> None: if self._state_name is None: return @@ -218,6 +347,8 @@ class BeamlineStatePill(BECWidget, QWidget): self._icon_label.setPixmap( material_icon(icon_name, size=(20, 20), color=on_accent, filled=True) ) + expand_icon = "expand_less" if self._expanded else "expand_more" + self._expand_button.setIcon(material_icon(expand_icon, convert_to_pixmap=False)) self._status_label.setText(self._STATUS_LABELS[self._status]) self._detail_label.setText(self._label) self.setToolTip(self._label) @@ -248,8 +379,175 @@ class BeamlineStatePill(BECWidget, QWidget): f"color: {colors['muted']};" "font-size: 11px;" "}" + "QWidget#beamline_state_settings {" + f"border-top: 1px solid {colors['border']};" + "}" + "QPushButton#beamline_state_remove_button {" + "background-color: #cc181e;" + "border: 1px solid #cc181e;" + "color: white;" + "border-radius: 4px;" + "padding: 4px 10px;" + "}" + "QPushButton#beamline_state_remove_button:hover {" + "background-color: #a91419;" + "border-color: #a91419;" + "}" ) + def _toggle_expanded(self) -> None: + self._expanded = not self._expanded + self._settings.setVisible(self._expanded) + self._apply_visual_state() + + def _populate_settings(self) -> None: + state_type = self._state_type() + self._state_type_value.setText(state_type or "-") + self._name_value.setText(self._state_name or "-") + self._title_edit.setText(str(self._state_field("title") or "")) + device = str(self._state_field("device") or "") + signal = str(self._state_field("signal") or "") + self._set_settings_device(device) + self._set_settings_signal(signal) + + show_limits = state_type == "DeviceWithinLimitsState" or any( + self._state_field(key) is not None for key in ("low_limit", "high_limit", "tolerance") + ) + for widget in self._limit_widgets: + widget.setVisible(show_limits) + + if not show_limits: + return + + self._set_optional_limit( + self._low_limit_enabled, self._low_limit, self._state_field("low_limit") + ) + self._set_optional_limit( + self._high_limit_enabled, self._high_limit, self._state_field("high_limit") + ) + tolerance = self._state_field("tolerance") + self._tolerance.setValue(float(tolerance) if tolerance is not None else 0.1) + + def edited_parameters(self) -> dict[str, Any]: + """Return editable parameters from the expanded settings panel.""" + device = self._device_edit.currentText().strip() + signal = self._optional_signal() + if not device: + raise ValueError("Device is required.") + + params: dict[str, Any] = { + "title": self._optional_text(self._title_edit), + "device": device, + "signal": signal, + } + if self._state_type() == "DeviceWithinLimitsState": + params.update( + { + "low_limit": ( + self._low_limit.value() if self._low_limit_enabled.isChecked() else None + ), + "high_limit": ( + self._high_limit.value() if self._high_limit_enabled.isChecked() else None + ), + "tolerance": self._tolerance.value(), + } + ) + return params + + def _emit_update_requested(self) -> None: + if self._state_name is None: + return + try: + parameters = self.edited_parameters() + except ValueError as exc: + QMessageBox.warning(self, "Invalid Beamline State", str(exc)) + return + self.update_requested.emit(self._state_name, parameters) + + def _emit_remove_requested(self) -> None: + if self._state_name is None: + return + self.remove_requested.emit(self._state_name) + + def _state_field(self, name: str) -> Any: + parameters = self._state_config.get("parameters") + if isinstance(parameters, dict) and name in parameters: + return parameters.get(name) + return self._state_config.get(name) + + def _state_type(self) -> str: + return str(self._state_config.get("state_type") or self._state_field("state_type") or "") + + def _on_settings_device_selected(self, device: str) -> None: + self._signal_edit.set_device(device) + + def _on_settings_device_reset(self) -> None: + self._signal_edit.set_device(None) + + def _set_settings_device(self, device: str) -> None: + if not device: + self._device_edit.setCurrentText("") + self._signal_edit.set_device(None) + return + self._device_edit.set_device(device) + if self._device_edit.currentText() != device: + self._device_edit.setCurrentText(device) + if self._device_edit.is_valid_input: + self._signal_edit.set_device(device) + + def _set_settings_signal(self, signal: str) -> None: + if not signal: + self._signal_edit.setCurrentText("") + return + self._signal_edit.set_signal(signal) + if ( + self._signal_edit.currentText() != signal + and self._signal_edit.get_signal_name() != signal + ): + self._signal_edit.setCurrentText(signal) + + @staticmethod + def _optional_text(line_edit: QLineEdit) -> str | None: + value = line_edit.text().strip() + return value or None + + def _optional_signal(self) -> str | None: + value = self._signal_edit.get_signal_name().strip() + return value or None + + @staticmethod + def _configure_settings_spinbox(spin_box: BECSpinBox) -> None: + spin_box.setRange(-1_000_000_000, 1_000_000_000) + spin_box.setDecimals(6) + spin_box.setFixedWidth(140) + + def _create_optional_limit_row(self) -> tuple[QCheckBox, BECSpinBox]: + container = QWidget(self) + checkbox = QCheckBox("Enabled", container) + spin_box = BECSpinBox(container) + self._configure_settings_spinbox(spin_box) + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + layout.addWidget(checkbox) + layout.addWidget(spin_box) + layout.addStretch(1) + return checkbox, spin_box + + def _create_settings_label(self, text: str) -> QLabel: + label = QLabel(text, self._settings) + label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + label.setTextFormat(Qt.TextFormat.PlainText) + return label + + @staticmethod + def _set_optional_limit(checkbox: QCheckBox, spin_box: BECSpinBox, value: Any) -> None: + enabled = value is not None + checkbox.setChecked(enabled) + spin_box.setEnabled(enabled) + if enabled: + spin_box.setValue(float(value)) + def _clear_state_flash(self) -> None: self._flash_active = False self._apply_visual_state() @@ -413,8 +711,14 @@ class AddBeamlineStateDialog(QDialog): if self._cleaned_up: return self._cleaned_up = True - self._device.device_selected.disconnect(self._on_valid_device_selected) - + try: + self._device.device_selected.disconnect(self._on_valid_device_selected) + except RuntimeError: + pass + try: + self._device.device_reset.disconnect(self._on_device_reset) + except RuntimeError: + pass self._device.close() self._device.deleteLater() self._signal.close() @@ -818,16 +1122,20 @@ class BeamlineStateManager(BECWidget, QWidget): title = state.get("title") or name if name in self._state_pills: self._state_pills[name].set_state_name(name, title=title) + self._state_pills[name].set_state_config(state) continue - self._add_pill(name, title=title) + self._add_pill(name, title=title, state_config=state) self._apply_filters() - def _add_pill(self, name: str, title: str) -> None: + def _add_pill(self, name: str, title: str, state_config: dict[str, Any]) -> None: pill = BeamlineStatePill( parent=self._content, state_name=name, title=title, client=self.client ) + pill.set_state_config(state_config) pill.state_changed.connect(self._on_pill_state_changed) + pill.update_requested.connect(self._update_state_parameters) + pill.remove_requested.connect(self._remove_state_requested) self._state_pills[name] = pill def _remove_pill(self, name: str) -> None: @@ -836,6 +1144,14 @@ class BeamlineStateManager(BECWidget, QWidget): pill.state_changed.disconnect(self._on_pill_state_changed) except RuntimeError: pass + try: + pill.update_requested.disconnect(self._update_state_parameters) + except RuntimeError: + pass + try: + pill.remove_requested.disconnect(self._remove_state_requested) + except RuntimeError: + pass pill.cleanup() self._content_layout.removeWidget(pill) self._hidden_content_layout.removeWidget(pill) @@ -893,6 +1209,43 @@ class BeamlineStateManager(BECWidget, QWidget): if self._selected_statuses is not None: self._apply_filters() + @Slot(str, dict) + def _update_state_parameters(self, state_name: str, parameters: dict[str, Any]) -> None: + beamline_states = getattr(self.client, "beamline_states", None) + state_client = getattr(beamline_states, state_name, None) if beamline_states else None + if state_client is None or not hasattr(state_client, "update_parameters"): + QMessageBox.warning( + self, "Cannot Update State", f"Beamline state '{state_name}' is not available." + ) + return + try: + state_client.update_parameters(**parameters) + except Exception as exc: + QMessageBox.warning(self, "Cannot Update State", str(exc)) + + @Slot(str) + def _remove_state_requested(self, state_name: str) -> None: + reply = QMessageBox.question( + self, + "Remove Beamline State", + f"Remove beamline state '{state_name}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + + beamline_states = getattr(self.client, "beamline_states", None) + if beamline_states is None or not hasattr(beamline_states, "delete"): + QMessageBox.warning( + self, "Cannot Remove State", "BEC client has no beamline state manager." + ) + return + try: + beamline_states.delete(state_name) + except Exception as exc: + QMessageBox.warning(self, "Cannot Remove State", str(exc)) + def _toggle_hidden_states(self, checked: bool) -> None: self._hidden_expanded = checked self._apply_filters() @@ -934,7 +1287,11 @@ class BeamlineStateManager(BECWidget, QWidget): if isinstance(state, dict): return state if hasattr(state, "model_dump"): - return state.model_dump() + state_dict = state.model_dump() + state_type = getattr(state, "state_type", None) + if state_type is not None: + state_dict.setdefault("state_type", state_type) + return state_dict return {"name": getattr(state, "name", None), "title": getattr(state, "title", None)} def cleanup(self) -> None: diff --git a/tests/unit_tests/test_beamline_state_pill.py b/tests/unit_tests/test_beamline_state_pill.py index 900b9a6e..2a709baa 100644 --- a/tests/unit_tests/test_beamline_state_pill.py +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -1,6 +1,7 @@ import shiboken6 from bec_lib import messages -from qtpy.QtCore import QCoreApplication, QEvent +from qtpy.QtCore import QCoreApplication, QEvent, Qt +from qtpy.QtWidgets import QMessageBox from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.widgets.services.beamline_states.beamline_state_pill import ( @@ -8,6 +9,8 @@ from bec_widgets.widgets.services.beamline_states.beamline_state_pill import ( BeamlineStateManager, BeamlineStatePill, ) +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox from .client_mocks import mocked_client @@ -42,6 +45,48 @@ def test_beamline_state_pill_ignores_other_states(qtbot, mocked_client): assert widget.toolTip() == "No state information available." +def test_beamline_state_pill_expands_and_emits_updated_limits(qtbot, mocked_client): + widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client) + qtbot.addWidget(widget) + widget.set_state_config( + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": { + "name": "limits", + "title": "Limits", + "device": "samx", + "signal": "samx", + "low_limit": 0.0, + "high_limit": 10.0, + "tolerance": 0.1, + }, + } + ) + + assert widget._settings.isHidden() + + qtbot.mouseClick(widget._header, Qt.MouseButton.LeftButton) + widget._high_limit.setValue(20.0) + + assert not widget._settings.isHidden() + assert isinstance(widget._device_edit, DeviceComboBox) + assert isinstance(widget._signal_edit, SignalComboBox) + assert widget._device_edit.currentText() == "samx" + assert widget.edited_parameters()["high_limit"] == 20.0 + + with qtbot.waitSignal(widget.update_requested) as signal: + widget._update_button.click() + + assert signal.args[0] == "limits" + assert signal.args[1]["device"] == "samx" + assert signal.args[1]["signal"] == "samx" + assert signal.args[1]["low_limit"] == 0.0 + assert signal.args[1]["high_limit"] == 20.0 + assert signal.args[1]["tolerance"] == 0.1 + + def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client): widget = BeamlineStateManager(client=mocked_client) qtbot.addWidget(widget) @@ -194,6 +239,51 @@ def test_beamline_state_manager_filters_devices(qtbot, mocked_client): assert widget._available_devices() == ["samx", "samy"] +def test_beamline_state_manager_updates_state_parameters(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + + class StateClient: + def __init__(self): + self.parameters = None + + def update_parameters(self, **kwargs): + self.parameters = kwargs + + class StateManager: + def __init__(self): + self.limits = StateClient() + + mocked_client.beamline_states = StateManager() + widget._update_state_parameters("limits", {"low_limit": -1.0, "high_limit": 20.0}) + + assert mocked_client.beamline_states.limits.parameters == { + "low_limit": -1.0, + "high_limit": 20.0, + } + + +def test_beamline_state_manager_removes_state(qtbot, mocked_client, monkeypatch): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + + class StateManager: + def __init__(self): + self.deleted = None + + def delete(self, state_name): + self.deleted = state_name + + mocked_client.beamline_states = StateManager() + monkeypatch.setattr( + QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.Yes + ) + + widget._remove_state_requested("limits") + + assert mocked_client.beamline_states.deleted == "limits" + + def test_add_beamline_state_dialog_uses_device_signal_widgets_and_normalizes_name( qtbot, mocked_client ):