From 67aa66edc9735feeef9b0d188bc61df783eb962a Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 29 May 2026 16:02:57 +0200 Subject: [PATCH] wip adding dialog adjusted --- .../beamline_states/beamline_state_pill.py | 119 ++++++++++++------ tests/unit_tests/test_beamline_state_pill.py | 68 +++++++++- 2 files changed, 143 insertions(+), 44 deletions(-) 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 44b4b9a4..0e5e3b77 100644 --- a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py @@ -15,7 +15,6 @@ from qtpy.QtWidgets import ( QComboBox, QDialog, QDialogButtonBox, - QDoubleSpinBox, QFormLayout, QGroupBox, QHBoxLayout, @@ -37,6 +36,7 @@ from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar 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 class BeamlineStatePill(BECWidget, QWidget): @@ -331,11 +331,11 @@ class AddBeamlineStateDialog(QDialog): self._title = QLineEdit(self) self._device = DeviceComboBox(parent=self, client=client) self._signal = SignalComboBox(parent=self, client=client, require_device=True) - self._low_limit = QDoubleSpinBox(self) - self._high_limit = QDoubleSpinBox(self) - self._tolerance = QDoubleSpinBox(self) + self._low_limit = BECSpinBox(self) + self._high_limit = BECSpinBox(self) + self._tolerance = BECSpinBox(self) self._device.device_selected.connect(self._on_valid_device_selected) - self._device.device_reset.connect(lambda: self._signal.set_device(None)) + self._device.device_reset.connect(self._on_device_reset) for spin_box in (self._low_limit, self._high_limit): spin_box.setRange(-1_000_000_000, 1_000_000_000) @@ -345,6 +345,10 @@ class AddBeamlineStateDialog(QDialog): self._tolerance.setRange(0.0, 1_000_000_000) self._tolerance.setDecimals(6) self._tolerance.setValue(0.1) + for field in self._input_fields(): + field.setFixedWidth(280) + for spin_box in (self._low_limit, self._high_limit, self._tolerance): + spin_box.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) self._form = QFormLayout() self._form.addRow("State type", self._type_combo) @@ -367,6 +371,7 @@ class AddBeamlineStateDialog(QDialog): layout.addWidget(self._buttons) self.setLayout(layout) self._update_field_visibility() + self.setFixedSize(self.sizeHint().expandedTo(self.minimumSizeHint())) def config(self) -> bl_states.DeviceStateConfig | bl_states.DeviceWithinLimitsStateConfig: state_type = self._type_combo.currentData() @@ -408,16 +413,16 @@ class AddBeamlineStateDialog(QDialog): if self._cleaned_up: return self._cleaned_up = True - self._device.cleanup() - self._signal.cleanup() + self._device.device_selected.disconnect(self._on_valid_device_selected) - def done(self, result: int) -> None: - try: - self.cleanup() - finally: - super().done(result) + self._device.close() + self._device.deleteLater() + self._signal.close() + self._signal.deleteLater() def _on_valid_device_selected(self, device: str) -> None: + if self._cleaned_up: + return self._signal.set_device(device) current_name = self._name.text().strip() if current_name and current_name != self._auto_generated_name: @@ -426,6 +431,11 @@ class AddBeamlineStateDialog(QDialog): self._auto_generated_name = generated_name self._name.setText(generated_name) + def _on_device_reset(self) -> None: + if self._cleaned_up: + return + self._signal.set_device(None) + def _update_field_visibility(self) -> None: show_limits = self._type_combo.currentData() == "device_within_limits" for widget in (self._low_limit, self._high_limit, self._tolerance): @@ -480,18 +490,25 @@ class AddBeamlineStateDialog(QDialog): return "limits" return "state" + def _input_fields(self) -> tuple[QWidget, ...]: + return ( + self._type_combo, + self._name, + self._title, + self._device, + self._signal, + self._low_limit, + self._high_limit, + self._tolerance, + ) -class StateFilterDialog(QDialog): - """Dialog for selecting visible beamline states.""" - def __init__( - self, - state_configs: dict[str, dict[str, Any]], - selected_state_names: set[str] | None, - parent: QWidget | None = None, - ) -> None: +class StatusFilterDialog(QDialog): + """Dialog for selecting visible beamline state statuses.""" + + def __init__(self, selected_statuses: set[str] | None, parent: QWidget | None = None) -> None: super().__init__(parent=parent) - self.setWindowTitle("Filter Beamline States") + self.setWindowTitle("Filter Beamline State Status") self._checkboxes: dict[str, QCheckBox] = {} controls = QHBoxLayout() @@ -504,15 +521,14 @@ class StateFilterDialog(QDialog): controls.addStretch(1) list_layout = QVBoxLayout() - for name, state in sorted(state_configs.items()): - checkbox = QCheckBox(str(state.get("title") or name), self) - checkbox.setChecked(selected_state_names is None or name in selected_state_names) - checkbox.setToolTip(name) - self._checkboxes[name] = checkbox + for status, label in BeamlineStatePill._STATUS_LABELS.items(): + checkbox = QCheckBox(label, self) + checkbox.setChecked(selected_statuses is None or status in selected_statuses) + self._checkboxes[status] = checkbox list_layout.addWidget(checkbox) list_layout.addStretch(1) - box = QGroupBox("Displayed states", self) + box = QGroupBox("Displayed status", self) box.setLayout(list_layout) buttons = QDialogButtonBox( @@ -527,8 +543,8 @@ class StateFilterDialog(QDialog): layout.addWidget(buttons) self.setLayout(layout) - def selected_state_names(self) -> set[str] | None: - selected = {name for name, checkbox in self._checkboxes.items() if checkbox.isChecked()} + def selected_statuses(self) -> set[str] | None: + selected = {status for status, checkbox in self._checkboxes.items() if checkbox.isChecked()} if selected == set(self._checkboxes): return None return selected @@ -613,12 +629,14 @@ class BeamlineStateManager(BECWidget, QWidget): self._state_pills: dict[str, BeamlineStatePill] = {} self._state_configs: dict[str, dict[str, Any]] = {} self._state_order: list[str] = [] - self._selected_state_names: set[str] | None = None + self._selected_statuses: set[str] | None = None self._selected_devices: set[str] | None = None self._device_filter_text = "" self._hidden_expanded = False - self._empty_label = QLabel("No beamline states available.\n Add new state from toolbar or CLI.", self) + self._empty_label = QLabel( + "No beamline states available.\n Add new state from toolbar or CLI.", self + ) self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._toolbar = self._create_toolbar() @@ -667,7 +685,7 @@ class BeamlineStateManager(BECWidget, QWidget): add_state = MaterialIconAction("add", "Add beamline state", filled=True, parent=self) filter_states = MaterialIconAction( - "filter_alt", "Filter displayed states", filled=True, parent=self + "filter_alt", "Filter displayed state status", filled=True, parent=self ) filter_devices = MaterialIconAction( "devices", "Filter displayed devices", filled=True, parent=self @@ -680,7 +698,7 @@ class BeamlineStateManager(BECWidget, QWidget): ) add_state.action.triggered.connect(self.open_add_state_dialog) - filter_states.action.triggered.connect(self.open_state_filter_dialog) + filter_states.action.triggered.connect(self.open_status_filter_dialog) filter_devices.action.triggered.connect(self.open_device_filter_dialog) clear_filters.action.triggered.connect(self.clear_filters) refresh.action.triggered.connect(self.refresh_states) @@ -721,7 +739,16 @@ class BeamlineStateManager(BECWidget, QWidget): @Slot() def open_add_state_dialog(self) -> None: dialog = AddBeamlineStateDialog(self, client=self.client) - if dialog.exec() != QDialog.Accepted: + config = None + try: + accepted = dialog.exec() == QDialog.Accepted + if accepted: + config = dialog.config_result + finally: + dialog.cleanup() + dialog.deleteLater() + + if config is None: return beamline_states = getattr(self.client, "beamline_states", None) if beamline_states is None: @@ -730,16 +757,16 @@ class BeamlineStateManager(BECWidget, QWidget): ) return try: - beamline_states.add(dialog.config_result) + beamline_states.add(config) except Exception as exc: QMessageBox.warning(self, "Cannot Add State", str(exc)) @Slot() - def open_state_filter_dialog(self) -> None: - dialog = StateFilterDialog(self._state_configs, self._selected_state_names, self) + def open_status_filter_dialog(self) -> None: + dialog = StatusFilterDialog(self._selected_statuses, self) if dialog.exec() != QDialog.Accepted: return - self._selected_state_names = dialog.selected_state_names() + self._selected_statuses = dialog.selected_statuses() self._apply_filters() @Slot() @@ -755,7 +782,7 @@ class BeamlineStateManager(BECWidget, QWidget): @Slot() def clear_filters(self) -> None: - self._selected_state_names = None + self._selected_statuses = None self._selected_devices = None self._device_filter_text = "" self._hidden_expanded = False @@ -800,10 +827,15 @@ class BeamlineStateManager(BECWidget, QWidget): pill = BeamlineStatePill( parent=self._content, state_name=name, title=title, client=self.client ) + pill.state_changed.connect(self._on_pill_state_changed) self._state_pills[name] = pill def _remove_pill(self, name: str) -> None: pill = self._state_pills.pop(name) + try: + pill.state_changed.disconnect(self._on_pill_state_changed) + except RuntimeError: + pass pill.cleanup() self._content_layout.removeWidget(pill) self._hidden_content_layout.removeWidget(pill) @@ -834,7 +866,10 @@ class BeamlineStateManager(BECWidget, QWidget): pill.setVisible(True) def _is_state_visible(self, name: str) -> bool: - if self._selected_state_names is not None and name not in self._selected_state_names: + pill = self._state_pills.get(name) + if self._selected_statuses is not None and ( + pill is None or pill._status not in self._selected_statuses + ): return False device = self._state_device(self._state_configs.get(name, {})) @@ -854,6 +889,10 @@ class BeamlineStateManager(BECWidget, QWidget): return False return True + def _on_pill_state_changed(self, _name: str, _status: str, _label: str) -> None: + if self._selected_statuses is not None: + self._apply_filters() + def _toggle_hidden_states(self, checked: bool) -> None: self._hidden_expanded = checked self._apply_filters() diff --git a/tests/unit_tests/test_beamline_state_pill.py b/tests/unit_tests/test_beamline_state_pill.py index aed57665..900b9a6e 100644 --- a/tests/unit_tests/test_beamline_state_pill.py +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -1,4 +1,6 @@ +import shiboken6 from bec_lib import messages +from qtpy.QtCore import QCoreApplication, QEvent from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.widgets.services.beamline_states.beamline_state_pill import ( @@ -6,6 +8,7 @@ from bec_widgets.widgets.services.beamline_states.beamline_state_pill import ( BeamlineStateManager, BeamlineStatePill, ) +from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox from .client_mocks import mocked_client @@ -81,7 +84,7 @@ def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client): assert sorted(widget._state_pills) == ["limits"] -def test_beamline_state_manager_filters_states(qtbot, mocked_client): +def test_beamline_state_manager_filters_status(qtbot, mocked_client): widget = BeamlineStateManager(client=mocked_client) qtbot.addWidget(widget) @@ -107,7 +110,13 @@ def test_beamline_state_manager_filters_states(qtbot, mocked_client): assert isinstance(widget._toolbar, ModularToolBar) - widget._selected_state_names = {"limits"} + widget._state_pills["limits"].update_state( + {"name": "limits", "status": "valid", "label": "Within limits."}, {} + ) + widget._state_pills["shutter_open"].update_state( + {"name": "shutter_open", "status": "invalid", "label": "Closed."}, {} + ) + widget._selected_statuses = {"valid"} widget._apply_filters() assert not widget._hidden_summary.isHidden() @@ -120,6 +129,39 @@ def test_beamline_state_manager_filters_states(qtbot, mocked_client): assert not widget._hidden_content.isHidden() +def test_beamline_state_manager_status_filter_reacts_to_state_changes(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + + widget.update_available_states( + { + "states": [ + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": {"device": "samx"}, + } + ] + }, + {}, + ) + + widget._selected_statuses = {"valid"} + widget._state_pills["limits"].update_state( + {"name": "limits", "status": "valid", "label": "Within limits."}, {} + ) + + assert widget._hidden_summary.isHidden() + + widget._state_pills["limits"].update_state( + {"name": "limits", "status": "invalid", "label": "Out of limits."}, {} + ) + + assert not widget._hidden_summary.isHidden() + assert widget._state_pills["limits"].parent() is widget._hidden_content + + def test_beamline_state_manager_filters_devices(qtbot, mocked_client): widget = BeamlineStateManager(client=mocked_client) qtbot.addWidget(widget) @@ -158,7 +200,6 @@ def test_add_beamline_state_dialog_uses_device_signal_widgets_and_normalizes_nam dialog = AddBeamlineStateDialog(client=mocked_client) qtbot.addWidget(dialog) - dialog._type_combo.setCurrentIndex(1) dialog._name.setText("samx-limits") dialog._title.setText("samx-limits-15") dialog._device.set_device("samx") @@ -173,6 +214,9 @@ def test_add_beamline_state_dialog_uses_device_signal_widgets_and_normalizes_nam assert config.signal == "samx" assert config.low_limit == 0.0 assert config.high_limit == 15.0 + assert isinstance(dialog._low_limit, BECSpinBox) + assert isinstance(dialog._high_limit, BECSpinBox) + assert dialog._low_limit.width() == dialog._device.width() def test_add_beamline_state_dialog_generates_name_only_after_valid_device_selection( @@ -181,7 +225,6 @@ def test_add_beamline_state_dialog_generates_name_only_after_valid_device_select dialog = AddBeamlineStateDialog(client=mocked_client) qtbot.addWidget(dialog) - dialog._type_combo.setCurrentIndex(1) dialog._device.setCurrentText("s") assert dialog._name.text() == "" @@ -189,3 +232,20 @@ def test_add_beamline_state_dialog_generates_name_only_after_valid_device_select dialog._device.set_device("samx") assert dialog._name.text() == "samx_limits" + + +def test_add_beamline_state_dialog_cleanup_deletes_device_widgets(qtbot, mocked_client): + dialog = AddBeamlineStateDialog(client=mocked_client) + qtbot.addWidget(dialog) + device = dialog._device + signal = dialog._signal + + dialog.reject() + assert shiboken6.isValid(device) + assert shiboken6.isValid(signal) + + dialog.cleanup() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + + assert not shiboken6.isValid(device) + assert not shiboken6.isValid(signal)