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 1422fe34..91476441 100644 --- a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py @@ -8,7 +8,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 QByteArray, QEvent, QMimeData, QPoint, Qt, QTimer, Signal, Slot +from qtpy.QtCore import QByteArray, QEvent, QMimeData, QPoint, Qt, QTimer, Signal from qtpy.QtGui import QColor, QCursor, QDrag, QPalette from qtpy.QtWidgets import ( QApplication, @@ -33,6 +33,7 @@ from qtpy.QtWidgets import ( from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar @@ -41,6 +42,12 @@ from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox im from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox +def _coerce_bool(value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + class BeamlineStatePill(BECWidget, QWidget): """ Compact widget showing one BEC beamline state. @@ -71,7 +78,6 @@ class BeamlineStatePill(BECWidget, QWidget): } _SETTINGS_FIELD_WIDTH = 280 _VALID_DRAG_PAYLOAD_MODES = {"config", "device"} - _VALID_CARD_BACKGROUND_MODES = {"hover", "always"} MIME_PAYLOAD = "application/x-bec-beamline-state-payload" MIME_CONFIG = "application/x-bec-beamline-state-config" MIME_NAME = "application/x-bec-beamline-state-name" @@ -101,7 +107,7 @@ class BeamlineStatePill(BECWidget, QWidget): self._flash_active = False self._expanded = False self._hovered = False - self._card_background_mode = "hover" + self._idle_card_background = False self._drag_payload_mode = "config" self._drag_start_position: QPoint | None = None self._drag_started = False @@ -301,11 +307,15 @@ class BeamlineStatePill(BECWidget, QWidget): QTimer.singleShot(0, self._sync_hover_state) super().leaveEvent(event) - @property + @SafeProperty(str, default=None) def state_name(self) -> str | None: """Name of the BEC beamline state displayed by this pill.""" return self._state_name + @state_name.setter + def state_name(self, state_name: str | None) -> None: + self.set_state_name(state_name) + def set_state_name(self, state_name: str | None, title: str | None = None) -> None: """ Set the BEC beamline state this pill displays. @@ -341,7 +351,7 @@ class BeamlineStatePill(BECWidget, QWidget): self._state_config = state_config self._populate_settings() - @property + @SafeProperty(str) def drag_payload_mode(self) -> str: """ Payload mode used when dragging this pill. @@ -363,30 +373,21 @@ class BeamlineStatePill(BECWidget, QWidget): """Set the payload mode used for drag-and-drop.""" self.drag_payload_mode = mode - @property - def card_background_mode(self) -> str: + @SafeProperty(bool, default=False) + def idle_card_background(self) -> bool: """ - Background mode for the pill card. - - Supported values: - ``"hover"``: idle pills are transparent; hover/expanded pills use a card background. - ``"always"``: idle pills keep the status-tinted background. + Whether idle collapsed pills keep the status-tinted card background. """ - return self._card_background_mode + return self._idle_card_background - @card_background_mode.setter - def card_background_mode(self, mode: str) -> None: - if mode not in self._VALID_CARD_BACKGROUND_MODES: - valid_modes = ", ".join(sorted(self._VALID_CARD_BACKGROUND_MODES)) - raise ValueError( - f"Invalid card background mode '{mode}'. Expected one of: {valid_modes}." - ) - self._card_background_mode = mode + @idle_card_background.setter + def idle_card_background(self, enabled: bool) -> None: + self._idle_card_background = _coerce_bool(enabled) self._apply_visual_state() - def set_card_background_mode(self, mode: str) -> None: - """Set when the pill card background should be shown.""" - self.card_background_mode = mode + def set_idle_card_background(self, enabled: bool) -> None: + """Set whether idle collapsed pills keep the status-tinted card background.""" + self.idle_card_background = enabled def _refresh_latest_state(self) -> None: if self._state_name is None: @@ -401,7 +402,7 @@ class BeamlineStatePill(BECWidget, QWidget): if isinstance(content, dict): self.update_state(content, getattr(data, "metadata", {})) - @Slot(dict, dict) + @SafeSlot(dict, dict) def update_state( self, content: dict[str, Any], _metadata: dict[str, Any] | None = None ) -> None: @@ -418,7 +419,7 @@ class BeamlineStatePill(BECWidget, QWidget): self._set_visual_state(status, label, flash=status_changed) self.state_changed.emit(self._state_name or str(name or ""), status, label) - @Slot(str) + @SafeSlot(str) def apply_theme(self, _theme: str) -> None: self._apply_visual_state() @@ -438,7 +439,7 @@ class BeamlineStatePill(BECWidget, QWidget): accent = colors["accent"] on_accent = colors["on_accent"] active_card = self._hovered or self._expanded - show_idle_background = self._card_background_mode == "always" or self._flash_active + show_idle_background = self._idle_card_background or self._flash_active border = ( colors["flash_border"] if self._flash_active @@ -517,6 +518,7 @@ class BeamlineStatePill(BECWidget, QWidget): "}" ) + @SafeSlot() def _toggle_expanded(self) -> None: self._expanded = not self._expanded self._settings.setVisible(self._expanded) @@ -528,6 +530,7 @@ class BeamlineStatePill(BECWidget, QWidget): self._hovered = hovered self._apply_visual_state() + @SafeSlot() def _sync_hover_state(self) -> None: inside = self.rect().contains(self.mapFromGlobal(QCursor.pos())) self._set_hovered(inside) @@ -586,6 +589,7 @@ class BeamlineStatePill(BECWidget, QWidget): ) return params + @SafeSlot() def _emit_update_requested(self) -> None: if self._state_name is None: return @@ -596,6 +600,7 @@ class BeamlineStatePill(BECWidget, QWidget): return self.update_requested.emit(self._state_name, parameters) + @SafeSlot() def _emit_remove_requested(self) -> None: if self._state_name is None: return @@ -655,9 +660,11 @@ class BeamlineStatePill(BECWidget, QWidget): return event.position().toPoint() return event.pos() + @SafeSlot(str) def _on_settings_device_selected(self, device: str) -> None: self._signal_edit.set_device(device) + @SafeSlot() def _on_settings_device_reset(self) -> None: self._signal_edit.set_device(None) @@ -757,6 +764,7 @@ class BeamlineStatePill(BECWidget, QWidget): if label is not None: label.setVisible(visible) + @SafeSlot() def _clear_state_flash(self) -> None: self._flash_active = False self._apply_visual_state() @@ -949,6 +957,7 @@ class AddBeamlineStateDialog(QDialog): self._signal.close() self._signal.deleteLater() + @SafeSlot(str) def _on_valid_device_selected(self, device: str) -> None: if self._cleaned_up: return @@ -960,12 +969,14 @@ class AddBeamlineStateDialog(QDialog): self._auto_generated_name = generated_name self._name.setText(generated_name) + @SafeSlot() def _on_device_reset(self) -> None: if self._cleaned_up: return self._signal.set_device(None) - def _update_field_visibility(self) -> None: + @SafeSlot(int) + def _update_field_visibility(self, _index: int = 0) -> None: show_limits = self._type_combo.currentData() == "device_within_limits" for widget in (self._low_limit, self._high_limit, self._tolerance): widget.setVisible(show_limits) @@ -1145,8 +1156,8 @@ class BeamlineStateManager(BECWidget, QWidget): USER_ACCESS = [ "drag_payload_mode", "set_drag_payload_mode", - "card_background_mode", - "set_card_background_mode", + "idle_card_background", + "set_idle_card_background", "refresh_states", "clear_filters", "remove", @@ -1162,7 +1173,7 @@ class BeamlineStateManager(BECWidget, QWidget): config: ConnectionConfig | None = None, gui_id: str | None = None, drag_payload_mode: str = "config", - card_background_mode: str = "hover", + idle_card_background: bool = False, **kwargs, ) -> None: super().__init__( @@ -1176,9 +1187,9 @@ class BeamlineStateManager(BECWidget, QWidget): self._device_filter_text = "" self._hidden_expanded = False self._drag_payload_mode = "config" - self._card_background_mode = "hover" + self._idle_card_background = False self.drag_payload_mode = drag_payload_mode - self.card_background_mode = card_background_mode + self.idle_card_background = idle_card_background self._empty_label = QLabel( "No beamline states available.\n Add new state from toolbar or CLI.", self @@ -1226,7 +1237,7 @@ class BeamlineStateManager(BECWidget, QWidget): self.refresh_states() self._refresh_hidden_summary() - @property + @SafeProperty(str) def drag_payload_mode(self) -> str: """ Payload mode used by pills dragged from this manager. @@ -1250,31 +1261,22 @@ class BeamlineStateManager(BECWidget, QWidget): """Set the payload mode used for drag-and-drop.""" self.drag_payload_mode = mode - @property - def card_background_mode(self) -> str: + @SafeProperty(bool, default=False) + def idle_card_background(self) -> bool: """ - Background mode used by pills in this manager. - - Supported values: - ``"hover"``: idle pills are transparent; hover/expanded pills use a card background. - ``"always"``: idle pills keep the status-tinted background. + Whether idle collapsed pills keep the status-tinted card background. """ - return self._card_background_mode + return self._idle_card_background - @card_background_mode.setter - def card_background_mode(self, mode: str) -> None: - if mode not in BeamlineStatePill._VALID_CARD_BACKGROUND_MODES: - valid_modes = ", ".join(sorted(BeamlineStatePill._VALID_CARD_BACKGROUND_MODES)) - raise ValueError( - f"Invalid card background mode '{mode}'. Expected one of: {valid_modes}." - ) - self._card_background_mode = mode + @idle_card_background.setter + def idle_card_background(self, enabled: bool) -> None: + self._idle_card_background = _coerce_bool(enabled) for pill in self._state_pills.values(): - pill.card_background_mode = mode + pill.idle_card_background = self._idle_card_background - def set_card_background_mode(self, mode: str) -> None: - """Set when pill card backgrounds should be shown.""" - self.card_background_mode = mode + def set_idle_card_background(self, enabled: bool) -> None: + """Set whether idle collapsed pills keep the status-tinted card background.""" + self.idle_card_background = enabled def _create_toolbar(self) -> ModularToolBar: toolbar = ModularToolBar(parent=self) @@ -1315,7 +1317,7 @@ class BeamlineStateManager(BECWidget, QWidget): toolbar.show_bundles(["beamline_state_manager"]) return toolbar - @Slot(str) + @SafeSlot(str) def apply_theme(self, _theme: str) -> None: colors = BeamlineStatePill._state_colors("unknown") self.setStyleSheet( @@ -1332,7 +1334,7 @@ class BeamlineStateManager(BECWidget, QWidget): pill.apply_theme(_theme) self._refresh_hidden_summary() - @Slot() + @SafeSlot() def open_add_state_dialog(self) -> None: dialog = AddBeamlineStateDialog(self, client=self.client) config = None @@ -1357,7 +1359,7 @@ class BeamlineStateManager(BECWidget, QWidget): except Exception as exc: QMessageBox.warning(self, "Cannot Add State", str(exc)) - @Slot() + @SafeSlot() def open_status_filter_dialog(self) -> None: dialog = StatusFilterDialog(self._selected_statuses, self) if dialog.exec() != QDialog.Accepted: @@ -1365,7 +1367,7 @@ class BeamlineStateManager(BECWidget, QWidget): self._selected_statuses = dialog.selected_statuses() self._apply_filters() - @Slot() + @SafeSlot() def open_device_filter_dialog(self) -> None: dialog = DeviceFilterDialog( self._available_devices(), self._selected_devices, self._device_filter_text, self @@ -1376,7 +1378,7 @@ class BeamlineStateManager(BECWidget, QWidget): self._device_filter_text = dialog.filter_text() self._apply_filters() - @Slot() + @SafeSlot() def clear_filters(self) -> None: self._selected_statuses = None self._selected_devices = None @@ -1384,6 +1386,7 @@ class BeamlineStateManager(BECWidget, QWidget): self._hidden_expanded = False self._apply_filters() + @SafeSlot() def refresh_states(self) -> None: """Fetch the latest cached available beamline states and update the list immediately.""" msg_container = self.client.connector.get_last(MessageEndpoints.available_beamline_states()) @@ -1394,7 +1397,7 @@ class BeamlineStateManager(BECWidget, QWidget): if isinstance(content, dict): self.update_available_states(content, getattr(data, "metadata", {})) - @Slot(dict, dict) + @SafeSlot(dict, dict) def update_available_states( self, content: dict[str, Any], _metadata: dict[str, Any] | None = None ) -> None: @@ -1425,7 +1428,7 @@ class BeamlineStateManager(BECWidget, QWidget): parent=self._content, state_name=name, title=title, client=self.client ) pill.drag_payload_mode = self._drag_payload_mode - pill.card_background_mode = self._card_background_mode + pill.idle_card_background = self._idle_card_background pill.set_state_config(state_config) pill.state_changed.connect(self._on_pill_state_changed) pill.update_requested.connect(self._update_state_parameters) @@ -1499,11 +1502,12 @@ class BeamlineStateManager(BECWidget, QWidget): return False return True + @SafeSlot(str, str, str) def _on_pill_state_changed(self, _name: str, _status: str, _label: str) -> None: if self._selected_statuses is not None: self._apply_filters() - @Slot(str, dict) + @SafeSlot(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 @@ -1517,7 +1521,7 @@ class BeamlineStateManager(BECWidget, QWidget): except Exception as exc: QMessageBox.warning(self, "Cannot Update State", str(exc)) - @Slot(str) + @SafeSlot(str) def _remove_state_requested(self, state_name: str) -> None: reply = QMessageBox.question( self, @@ -1540,6 +1544,7 @@ class BeamlineStateManager(BECWidget, QWidget): except Exception as exc: QMessageBox.warning(self, "Cannot Remove State", str(exc)) + @SafeSlot(bool) def _toggle_hidden_states(self, checked: bool) -> None: self._hidden_expanded = checked self._apply_filters() @@ -1655,11 +1660,9 @@ if __name__ == "__main__": # pragma: no cover mode_combo.currentIndexChanged.connect( lambda: setattr(manager, "drag_payload_mode", mode_combo.currentData()) ) - background_combo = QComboBox(window) - background_combo.addItem("Card on hover", "hover") - background_combo.addItem("Card always", "always") - background_combo.currentIndexChanged.connect( - lambda: setattr(manager, "card_background_mode", background_combo.currentData()) + idle_background = QCheckBox("Idle card background", window) + idle_background.toggled.connect( + lambda checked: setattr(manager, "idle_card_background", checked) ) drop_list = BeamlineStateDropList(window) @@ -1668,8 +1671,7 @@ if __name__ == "__main__": # pragma: no cover payload_row = QHBoxLayout() payload_row.addWidget(QLabel("Drag payload", window)) payload_row.addWidget(mode_combo) - payload_row.addWidget(QLabel("Card background", window)) - payload_row.addWidget(background_combo) + payload_row.addWidget(idle_background) payload_row.addStretch(1) layout.addLayout(payload_row) diff --git a/tests/unit_tests/test_beamline_state_pill.py b/tests/unit_tests/test_beamline_state_pill.py index ba983a7c..17fc1b60 100644 --- a/tests/unit_tests/test_beamline_state_pill.py +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -111,7 +111,7 @@ def test_beamline_state_pill_can_keep_idle_background(qtbot, mocked_client): assert "#BeamlineStatePill {background: transparent" in widget.styleSheet() - widget.card_background_mode = "always" + widget.idle_card_background = True assert "#BeamlineStatePill {background: transparent" not in widget.styleSheet() assert "qlineargradient" not in widget.styleSheet() @@ -221,8 +221,8 @@ def test_beamline_state_manager_propagates_drag_payload_mode(qtbot, mocked_clien assert widget._state_pills["limits"].drag_payload_mode == "config" -def test_beamline_state_manager_propagates_card_background_mode(qtbot, mocked_client): - widget = BeamlineStateManager(client=mocked_client, card_background_mode="always") +def test_beamline_state_manager_propagates_idle_card_background(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client, idle_card_background=True) qtbot.addWidget(widget) widget.update_available_states( @@ -239,11 +239,11 @@ def test_beamline_state_manager_propagates_card_background_mode(qtbot, mocked_cl {}, ) - assert widget._state_pills["limits"].card_background_mode == "always" + assert widget._state_pills["limits"].idle_card_background is True - widget.card_background_mode = "hover" + widget.idle_card_background = False - assert widget._state_pills["limits"].card_background_mode == "hover" + assert widget._state_pills["limits"].idle_card_background is False def test_beamline_state_manager_filters_status(qtbot, mocked_client):