From 768c138576ad924dfad334888583131cc452e4f0 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 | 25 ++++- .../beamline_states/beamline_state_pill.py | 23 ++++- tests/unit_tests/test_beamline_state_pill.py | 93 ++++++++++++++++--- 4 files changed, 131 insertions(+), 16 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..7500635e 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,13 +396,20 @@ 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. + Return all beamline states (including filtered ones) with their current status and label. Returns: dict: Mapping of state name to a dictionary with ``status`` and ``label`` keys. """ + return { name: {"status": pill._status, "label": pill._label} for name, pill in self._state_pills.items() 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..4ce717be 100644 --- a/tests/unit_tests/test_beamline_state_pill.py +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -21,16 +21,34 @@ def _state(name: str, state_type: str, parameters: dict | None = None): ) -def _limits_state(name: str = "limits", **overrides): - parameters = { +def _wire_state( + state_class: type[bl_states.BeamlineState], config: bl_states.BeamlineStateConfig +) -> messages.BeamlineStateConfig: + return messages.BeamlineStateConfig( + name=config.name, + state_type=state_class.__name__, + parameters=config.model_dump(exclude={"name"}), + ) + + +def _limits_state(name: str = "limits", **overrides) -> messages.BeamlineStateConfig: + values = { "device": "samx", "signal": "samx", "low_limit": 0.0, "high_limit": 10.0, "tolerance": 0.1, } - parameters.update(overrides) - return _state(name, "DeviceWithinLimitsState", parameters) + values.update(overrides) + config = bl_states.DeviceWithinLimitsState.CONFIG_CLASS(name=name, **values) + return _wire_state(bl_states.DeviceWithinLimitsState, config) + + +def _shutter_state( + name: str = "shutter_open", device: str = "samy" +) -> messages.BeamlineStateConfig: + config = bl_states.ShutterState.CONFIG_CLASS(name=name, device=device) + return _wire_state(bl_states.ShutterState, config) def test_beamline_state_pill_updates_from_message(qtbot, mocked_client): @@ -132,6 +150,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()) @@ -251,13 +307,7 @@ def test_beamline_state_manager_propagates_idle_card_background(qtbot, mocked_cl def test_beamline_state_manager_filters_status(qtbot, mocked_client): beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client) beamline_state_manager.update_available_states( - { - "states": [ - _state("shutter_open", "ShutterState", {"device": "samy"}), - _state("limits", "DeviceWithinLimitsState", {"device": "samx"}), - ] - }, - {}, + {"states": [_shutter_state(), _limits_state()]}, {} ) assert isinstance(beamline_state_manager._toolbar, ModularToolBar) @@ -324,8 +374,8 @@ def test_beamline_state_manager_filters_devices(qtbot, mocked_client, monkeypatc beamline_state_manager.update_available_states( { "states": [ - _state("samx_limits", "DeviceWithinLimitsState", {"device": "samx"}), - _state("samy_limits", "DeviceWithinLimitsState", {"device": "samy"}), + _limits_state(name="samx_limits"), + _limits_state(name="samy_limits", device="samy"), ] }, {}, @@ -358,6 +408,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()]}, {})