From 389745138187ae433050c77810b123e52c568e2f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 11 Jun 2026 17:03:36 +0200 Subject: [PATCH] feat(beamline-states): collapse all functionality with cleanup of not used settings widgets if state is not dirty --- bec_widgets/cli/client.py | 6 ++ .../beamline_states/beamline_state_manager.py | 22 +++++++- .../beamline_states/beamline_state_pill.py | 23 +++++++- tests/unit_tests/test_beamline_state_pill.py | 55 +++++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index adf5a9e4..b08bef18 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -729,6 +729,12 @@ class BeamlineStateManager(RPCBase): None """ + @rpc_call + def collapse_all(self) -> "None": + """ + Collapse the settings panel of all displayed state pills. + """ + @rpc_call def state_summary(self) -> "dict[str, dict[str, str]]": """ diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_manager.py b/bec_widgets/widgets/services/beamline_states/beamline_state_manager.py index bd90cd9e..e8b069e9 100644 --- a/bec_widgets/widgets/services/beamline_states/beamline_state_manager.py +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_manager.py @@ -208,7 +208,15 @@ class BeamlineStateManager(BECWidget, QWidget): PLUGIN = True ICON_NAME = "format_list_bulleted" - USER_ACCESS = ["clear_filters", "state_summary", "remove", "attach", "detach", "screenshot"] + USER_ACCESS = [ + "clear_filters", + "collapse_all", + "state_summary", + "remove", + "attach", + "detach", + "screenshot", + ] def __init__( self, @@ -294,22 +302,28 @@ class BeamlineStateManager(BECWidget, QWidget): clear_filters = MaterialIconAction( "filter_alt_off", "Clear beamline state filters", filled=True, parent=self ) + collapse_all = MaterialIconAction( + "collapse_all", "Collapse all states", filled=True, parent=self + ) add_state.action.triggered.connect(self.open_add_state_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) + collapse_all.action.triggered.connect(self.collapse_all) toolbar.components.add_safe("add_state", add_state) toolbar.components.add_safe("filter_states", filter_states) toolbar.components.add_safe("filter_devices", filter_devices) toolbar.components.add_safe("clear_filters", clear_filters) + toolbar.components.add_safe("collapse_all", collapse_all) bundle = ToolbarBundle("beamline_state_manager", toolbar.components) bundle.add_action("add_state") bundle.add_action("filter_states") bundle.add_action("filter_devices") bundle.add_action("clear_filters") + bundle.add_action("collapse_all") toolbar.add_bundle(bundle) toolbar.show_bundles(["beamline_state_manager"]) return toolbar @@ -382,6 +396,12 @@ class BeamlineStateManager(BECWidget, QWidget): self._hidden_expanded = False self._apply_filters() + @SafeSlot() + def collapse_all(self) -> None: + """Collapse the settings panel of all displayed state pills.""" + for pill in self._state_pills.values(): + pill.set_expanded(False) + def state_summary(self) -> dict[str, dict[str, str]]: """ Return the displayed beamline states with their current status and label. 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 5ad1e6b7..7a4e74a2 100644 --- a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py @@ -410,7 +410,14 @@ class BeamlineStatePill(BECWidget, QWidget): return self._expanded def set_expanded(self, expanded: bool) -> None: - """Set the editable settings panel expanded state.""" + """ + Set the editable settings panel expanded state. + + The settings form is built on demand when the panel expands and released again on + collapse, so collapsed pills do not keep live device/signal widgets and their BEC + subscriptions around. Unsaved edits are discarded on collapse. + """ + expanded = bool(expanded) if expanded == self._expanded: return @@ -418,6 +425,8 @@ class BeamlineStatePill(BECWidget, QWidget): self._ensure_settings_form_current() self._expanded = expanded self._settings.setVisible(expanded) + if not expanded: + self._release_config_form() self._apply_visual_state() self.row_height_changed.emit() @@ -438,6 +447,18 @@ class BeamlineStatePill(BECWidget, QWidget): self.mark_current_settings_clean() return self._ensure_config_form() + def _release_config_form(self) -> None: + if self._config_form is None: + return + self._config_form_host.removeWidget(self._config_form) + self._config_form.cleanup() + self._config_form.setParent(None) + self._config_form.deleteLater() + self._config_form = None + self._settings_baseline = {} + self._settings_form_stale = True + self._update_settings_dirty_state() + def _populate_settings(self) -> None: self._populating_settings = True try: diff --git a/tests/unit_tests/test_beamline_state_pill.py b/tests/unit_tests/test_beamline_state_pill.py index 2c8d775e..24db6fff 100644 --- a/tests/unit_tests/test_beamline_state_pill.py +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -132,6 +132,44 @@ def test_beamline_state_pill_reverts_changed_settings(qtbot, mocked_client): ) +def test_beamline_state_pill_releases_form_on_collapse(qtbot, mocked_client): + limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client) + limits_pill.set_state_config(_limits_state()) + + limits_pill.set_expanded(True) + device_widget = limits_pill._config_form.input_widget("device") + + limits_pill.set_expanded(False) + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + + assert limits_pill._config_form is None + assert not shiboken6.isValid(device_widget) + + limits_pill.set_expanded(True) + + assert limits_pill._config_form is not None + assert limits_pill._config_form.input_widget("high_limit").value() == 10.0 + assert not limits_pill._update_button.isEnabled() + + +def test_beamline_state_pill_collapse_discards_unsaved_edits(qtbot, mocked_client): + limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client) + limits_pill.set_state_config(_limits_state()) + + limits_pill.set_expanded(True) + limits_pill._config_form.input_widget("high_limit").setValue(20.0) + assert limits_pill._update_button.isEnabled() + + limits_pill.set_expanded(False) + + assert limits_pill._config_form is None + + limits_pill.set_expanded(True) + + assert limits_pill._config_form.input_widget("high_limit").value() == 10.0 + assert not limits_pill._update_button.isEnabled() + + def test_beamline_state_pill_does_not_override_themed_input_controls(qtbot, mocked_client): limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client) limits_pill.set_state_config(_limits_state()) @@ -358,6 +396,23 @@ def test_beamline_state_manager_filters_devices(qtbot, mocked_client, monkeypatc assert captured["parent"] is beamline_state_manager +def test_beamline_state_manager_collapse_all(qtbot, mocked_client): + beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client) + beamline_state_manager.update_available_states( + {"states": [_limits_state(), _state("shutter_open", "ShutterState", {"device": "samy"})]}, + {}, + ) + + for pill in beamline_state_manager._state_pills.values(): + pill.set_expanded(True) + assert all(pill.is_expanded() for pill in beamline_state_manager._state_pills.values()) + + collapse_action = beamline_state_manager._toolbar.components.get_action("collapse_all") + collapse_action.action.trigger() + + assert not any(pill.is_expanded() for pill in beamline_state_manager._state_pills.values()) + + def test_beamline_state_manager_backend_echo_repopulates_expanded_pill(qtbot, mocked_client): beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client) beamline_state_manager.update_available_states({"states": [_limits_state()]}, {})