0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-13 19:21:50 +02:00

fix(plot_framework): all widgets, popups and side menus cleanups adjusted

This commit is contained in:
2025-04-08 17:18:52 +02:00
committed by wyzula-jan
parent a1bec75115
commit 337a332ed1
20 changed files with 225 additions and 74 deletions

View File

@ -14,10 +14,8 @@ if TYPE_CHECKING: # pragma: no cover
from bec_lib import messages
from bec_lib.connector import MessageObject
from bec_widgets.cli.client_utils import BECGuiClient
import bec_widgets.cli.client as client
from bec_widgets.cli.client_utils import BECGuiClient
else:
client = lazy_import("bec_widgets.cli.client") # avoid circular import
messages = lazy_import("bec_lib.messages")

View File

@ -77,13 +77,6 @@ class BECWidget(BECConnector):
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
self._connect_to_theme_change()
def _ensure_bec_app(self):
# pylint: disable=import-outside-toplevel
from bec_widgets.utils.bec_qapp import BECApplication
app = BECApplication.from_qapplication()
return app
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
@ -113,6 +106,7 @@ class BECWidget(BECConnector):
"""Cleanup the widget."""
with RPCRegister.delayed_broadcast():
# All widgets need to call super().cleanup() in their cleanup method
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
self.rpc_register.remove_rpc(self)
def closeEvent(self, event):

View File

@ -1,7 +1,11 @@
from bec_lib.logger import bec_logger
from PySide6.QtGui import QCloseEvent
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class SettingWidget(QWidget):
"""
@ -37,6 +41,15 @@ class SettingWidget(QWidget):
"""
pass
def cleanup(self):
"""
Cleanup the settings widget.
"""
def closeEvent(self, event: QCloseEvent) -> None:
self.cleanup()
return super().closeEvent(event)
class SettingsDialog(QDialog):
"""
@ -99,8 +112,17 @@ class SettingsDialog(QDialog):
Accept the changes made in the settings widget and close the dialog.
"""
self.widget.accept_changes()
self.cleanup()
super().accept()
@SafeSlot()
def reject(self):
"""
Reject the changes made in the settings widget and close the dialog.
"""
self.cleanup()
super().reject()
@SafeSlot()
def apply_changes(self):
"""
@ -114,7 +136,10 @@ class SettingsDialog(QDialog):
"""
self.button_box.close()
self.button_box.deleteLater()
self.widget.close()
self.widget.deleteLater()
def closeEvent(self, event):
logger.info("Closing settings dialog")
self.cleanup()
super().closeEvent(event)

View File

@ -133,7 +133,7 @@ class Image(PlotBase):
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
self._main_image.parent_image = self
self._main_image = ImageItem(parent_image=self, parent_id=self.gui_id)
self.plot_item.addItem(self._main_image)
self.scan_id = None
@ -913,10 +913,14 @@ class Image(PlotBase):
"""
Disconnect the image update signals and clean up the image.
"""
# Main Image cleanup
if self._main_image.config.monitor is not None:
self.disconnect_monitor(self._main_image.config.monitor)
self._main_image.config.monitor = None
self.plot_item.removeItem(self._main_image)
self._main_image = None
# Colorbar Cleanup
if self._color_bar:
if self.config.color_bar == "full":
self.cleanup_histogram_lut_item(self._color_bar)
@ -925,6 +929,10 @@ class Image(PlotBase):
self._color_bar.deleteLater()
self._color_bar = None
# Toolbar cleanup
self.toolbar.widgets["monitor"].widget.close()
self.toolbar.widgets["monitor"].widget.deleteLater()
super().cleanup()

View File

@ -28,7 +28,9 @@ class MonitorSelectionToolbarBundle(ToolbarBundle):
# 1) Device combo box
self.device_combo_box = DeviceComboBox(
device_filter=BECDeviceFilter.DEVICE, readout_priority_filter=[ReadoutPriority.ASYNC]
parent=self.target_widget,
device_filter=BECDeviceFilter.DEVICE,
readout_priority_filter=[ReadoutPriority.ASYNC],
)
self.device_combo_box.addItem("", None)
self.device_combo_box.setCurrentText("")

View File

@ -791,6 +791,10 @@ class MotorMap(PlotBase):
data = {"x": self._buffer["x"], "y": self._buffer["y"]}
return data
def cleanup(self):
self.motor_selection_bundle.cleanup()
super().cleanup()
class DemoApp(QMainWindow): # pragma: no cover
def __init__(self):

View File

@ -27,14 +27,18 @@ class MotorSelectionToolbarBundle(ToolbarBundle):
self.target_widget = target_widget
# Motor X
self.motor_x = DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
self.motor_x = DeviceComboBox(
parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER]
)
self.motor_x.addItem("", None)
self.motor_x.setCurrentText("")
self.motor_x.setToolTip("Select Motor X")
self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
# Motor X
self.motor_y = DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
self.motor_y = DeviceComboBox(
parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER]
)
self.motor_y.addItem("", None)
self.motor_y.setCurrentText("")
self.motor_y.setToolTip("Select Motor Y")
@ -58,3 +62,9 @@ class MotorSelectionToolbarBundle(ToolbarBundle):
or motor_y != self.target_widget.config.y_motor.name
):
self.target_widget.map(motor_x, motor_y)
def cleanup(self):
self.motor_x.close()
self.motor_x.deleteLater()
self.motor_y.close()
self.motor_y.deleteLater()

View File

@ -496,3 +496,9 @@ class MultiWaveform(PlotBase):
self.monitor_selection_bundle.colormap_widget.blockSignals(True)
self.monitor_selection_bundle.colormap_widget.colormap = self.config.color_palette
self.monitor_selection_bundle.colormap_widget.blockSignals(False)
def cleanup(self):
self._disconnect_monitor()
self.clear_curves()
self.monitor_selection_bundle.cleanup()
super().cleanup()

View File

@ -58,3 +58,10 @@ class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
@SafeSlot(str)
def change_colormap(self, colormap: str):
self.target_widget.color_palette = colormap
def cleanup(self):
"""
Cleanup the toolbar bundle.
"""
self.monitor.close()
self.monitor.deleteLater()

View File

@ -69,7 +69,7 @@ class PlotBase(BECWidget, QWidget):
config: ConnectionConfig | None = None,
client=None,
gui_id: str | None = None,
popups: bool = False,
popups: bool = True,
**kwargs,
) -> None:
if config is None:
@ -170,6 +170,9 @@ class PlotBase(BECWidget, QWidget):
# hide some options by default
self.toolbar.toggle_action_visibility("fps_monitor", False)
# Get default viewbox state
self.mouse_bundle.get_viewbox_mode()
def add_side_menus(self):
"""Adds multiple menus to the side panel."""
# Setting Axis Widget

View File

@ -107,14 +107,15 @@ class ScatterWaveform(PlotBase):
):
if config is None:
config = ScatterWaveformConfig(widget_class=self.__class__.__name__)
# Specific GUI elements
self.scatter_dialog = None
self.scatter_curve_settings = None
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
self._main_curve = ScatterCurve(parent_item=self)
# Specific GUI elements
self.scatter_dialog = None
# Scan Data
self.old_scan_id = None
self.scan_id = None
@ -128,24 +129,26 @@ class ScatterWaveform(PlotBase):
self.proxy_update_sync = pg.SignalProxy(
self.sync_signal_update, rateLimit=25, slot=self.update_sync_curves
)
self._init_scatter_curve_settings()
self.update_with_scan_history(-1)
################################################################################
# Widget Specific GUI interactions
################################################################################
def _init_scatter_curve_settings(self):
"""
Initialize the scatter curve settings menu.
"""
scatter_curve_settings = ScatterCurveSettings(parent=self, target_widget=self, popup=False)
self.scatter_curve_settings = ScatterCurveSettings(
parent=self, target_widget=self, popup=False
)
self.side_panel.add_menu(
action_id="scatter_curve",
icon_name="scatter_plot",
tooltip="Show Scatter Curve Settings",
widget=scatter_curve_settings,
widget=self.scatter_curve_settings,
title="Scatter Curve Settings",
)
@ -461,17 +464,30 @@ class ScatterWaveform(PlotBase):
logger.warning(f"Neither scan_id or scan_number was provided, fetching the latest scan")
scan_index = -1
if scan_index is not None:
if len(self.client.history) == 0:
logger.info("No scans executed so far. Skipping scan history update.")
return
self.scan_item = self.client.history[scan_index]
metadata = self.scan_item.metadata
self.scan_id = metadata["bec"]["scan_id"]
else:
if scan_index is None:
self.scan_id = scan_id
self.scan_item = self.client.history.get_by_scan_id(scan_id)
self.sync_signal_update.emit()
return
if scan_index == -1:
scan_item = self.client.queue.scan_storage.current_scan
if scan_item is not None:
if scan_item.status_message is None:
logger.warning(f"Scan item with {scan_item.scan_id} has no status message.")
return
self.scan_item = scan_item
self.scan_id = scan_item.scan_id
self.sync_signal_update.emit()
return
if len(self.client.history) == 0:
logger.info("No scans executed so far. Skipping scan history update.")
return
self.scan_item = self.client.history[scan_index]
metadata = self.scan_item.metadata
self.scan_id = metadata["bec"]["scan_id"]
self.sync_signal_update.emit()
@ -487,6 +503,22 @@ class ScatterWaveform(PlotBase):
self.crosshair.clear_markers()
self._main_curve.clear()
def cleanup(self):
"""
Cleanup the widget and disconnect all signals.
"""
if self.scatter_dialog is not None:
self.scatter_dialog.close()
self.scatter_dialog.deleteLater()
if self.scatter_curve_settings is not None:
self.scatter_curve_settings.cleanup()
print("scatter_curve_settings celanup called")
self.bec_dispatcher.disconnect_slot(self.on_scan_status, MessageEndpoints.scan_status())
self.bec_dispatcher.disconnect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
self.plot_item.removeItem(self._main_curve)
self._main_curve = None
super().cleanup()
class DemoApp(QMainWindow): # pragma: no cover
def __init__(self):

View File

@ -122,3 +122,17 @@ class ScatterCurveSettings(SettingWidget):
color_map=color_map,
validate_bec=validate_bec,
)
def cleanup(self):
self.ui.x_name.close()
self.ui.x_name.deleteLater()
self.ui.x_entry.close()
self.ui.x_entry.deleteLater()
self.ui.y_name.close()
self.ui.y_name.deleteLater()
self.ui.y_entry.close()
self.ui.y_entry.deleteLater()
self.ui.z_name.close()
self.ui.z_name.deleteLater()
self.ui.z_entry.close()
self.ui.z_entry.deleteLater()

View File

@ -56,9 +56,6 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
rect.action.toggled.connect(self.enable_mouse_rectangle_mode)
auto.action.triggered.connect(self.autorange_plot)
# Give some time to check the state
QTimer.singleShot(10, self.get_viewbox_mode)
def get_viewbox_mode(self):
"""
Returns the current interaction mode of a PyQtGraph ViewBox and sets the corresponding action.

View File

@ -93,7 +93,8 @@ class Curve(BECConnector, pg.PlotDataItem):
self.config = config
self.parent_item = parent_item
self.parent_id = self.parent_item.gui_id
super().__init__(name=name, config=config, gui_id=gui_id, **kwargs)
object_name = name.replace("-", "_") if name else None
super().__init__(name=name, object_name=object_name, config=config, gui_id=gui_id, **kwargs)
self.apply_config()
self.dap_params = None

View File

@ -28,7 +28,6 @@ class CurveSetting(SettingWidget):
def __init__(self, parent=None, target_widget: Waveform = None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
self.setProperty("skip_settings", True)
self.setObjectName("CurveSetting")
self.target_widget = target_widget
self.layout = QVBoxLayout(self)
@ -52,7 +51,7 @@ class CurveSetting(SettingWidget):
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.device_x_label = QLabel("Device")
self.device_x = DeviceLineEdit()
self.device_x = DeviceLineEdit(parent=self)
self.signal_x_label = QLabel("Signal")
self.signal_x = QLineEdit()
@ -117,3 +116,10 @@ class CurveSetting(SettingWidget):
"""Refresh the curve tree and the x axis combo box in the case Waveform is modified from rpc."""
self.curve_manager.refresh_from_waveform()
self._get_x_mode_from_waveform()
def cleanup(self):
"""Cleanup the widget."""
self.device_x.close()
self.device_x.deleteLater()
self.curve_manager.close()
self.curve_manager.deleteLater()

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import json
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
@ -36,6 +37,9 @@ if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.plots.waveform.waveform import Waveform
logger = bec_logger.logger
class ColorButton(QPushButton):
"""A QPushButton subclass that displays a color.
@ -110,11 +114,16 @@ class CurveRow(QTreeWidgetItem):
self.curve_tree = tree.parent() # The CurveTree widget
self.curve_tree.all_items.append(self) # Track stable ordering
# BEC user input
self.device_edit = None
self.dap_combo = None
self.dev = device_manager
self.entry_validator = EntryValidator(self.dev)
self.config = config or CurveConfig()
self.source = self.config.source
self.dap_rows = []
# Create column 0 (Actions)
self._init_actions()
@ -155,8 +164,8 @@ class CurveRow(QTreeWidgetItem):
"""Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo."""
if self.source == "device":
# Device row: columns 1..2 are device line edits
self.device_edit = DeviceLineEdit()
self.entry_edit = QLineEdit() # TODO in future will be signal line edit
self.device_edit = DeviceLineEdit(parent=self.tree)
self.entry_edit = QLineEdit(parent=self.tree) # TODO in future will be signal line edit
if self.config.signal:
self.device_edit.setText(self.config.signal.name or "")
self.entry_edit.setText(self.config.signal.entry or "")
@ -168,7 +177,7 @@ class CurveRow(QTreeWidgetItem):
# DAP row: column1= "Model" label, column2= DapComboBox
self.label_widget = QLabel("Model")
self.tree.setItemWidget(self, 1, self.label_widget)
self.dap_combo = DapComboBox()
self.dap_combo = DapComboBox(parent=self.tree)
self.dap_combo.populate_fit_model_combobox()
# If config.signal has a dap
if self.config.signal and self.config.signal.dap:
@ -258,15 +267,31 @@ class CurveRow(QTreeWidgetItem):
def remove_self(self):
"""Remove this row from the tree and from the parent's item list."""
# If top-level:
# Recursively remove all child rows first
for i in reversed(range(self.childCount())):
child = self.child(i)
if isinstance(child, CurveRow):
child.remove_self()
# Clean up the widget references if they still exist
if getattr(self, "device_edit", None) is not None:
self.device_edit.close()
self.device_edit.deleteLater()
self.device_edit = None
if getattr(self, "dap_combo", None) is not None:
self.dap_combo.close()
self.dap_combo.deleteLater()
self.dap_combo = None
# Remove the item from the tree widget
index = self.tree.indexOfTopLevelItem(self)
if index != -1:
self.tree.takeTopLevelItem(index)
else:
# If child item
if self.parent_item:
self.parent_item.removeChild(self)
# Also remove from all_items
elif self.parent_item:
self.parent_item.removeChild(self)
# Finally, remove self from the registration list in the curve tree
curve_tree = self.tree.parent()
if self in curve_tree.all_items:
curve_tree.all_items.remove(self)
@ -320,6 +345,10 @@ class CurveRow(QTreeWidgetItem):
return self.config.model_dump()
def closeEvent(self, event) -> None:
logger.info(f"CurveRow closeEvent: {self.config.label}")
return super().closeEvent(event)
class CurveTree(BECWidget, QWidget):
"""A tree widget that manages device and DAP curves."""
@ -535,3 +564,13 @@ class CurveTree(BECWidget, QWidget):
for dap in dap_curves:
if dap.config.parent_label == dev.config.label:
CurveRow(self.tree, parent_item=dr, config=dap.config, device_manager=self.dev)
def cleanup(self):
"""Cleanup the widget."""
all_items = list(self.all_items)
for item in all_items:
item.remove_self()
def closeEvent(self, event):
self.cleanup()
return super().closeEvent(event)

View File

@ -10,14 +10,7 @@ from bec_lib import bec_logger, messages
from bec_lib.endpoints import MessageEndpoints
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import QTimer, Signal
from qtpy.QtWidgets import (
QApplication,
QDialog,
QHBoxLayout,
QMainWindow,
QVBoxLayout,
QWidget,
)
from qtpy.QtWidgets import QApplication, QDialog, QHBoxLayout, QMainWindow, QVBoxLayout, QWidget
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
@ -320,6 +313,8 @@ class Waveform(PlotBase):
"""
Slot for when the axis settings dialog is closed.
"""
self.curve_settings_dialog.close()
self.curve_settings_dialog.deleteLater()
self.curve_settings_dialog = None
self.toolbar.widgets["curve"].action.setChecked(False)
@ -863,6 +858,9 @@ class Waveform(PlotBase):
Clear all curves from the plot widget.
"""
curve_list = self.curves
self._dap_curves = []
self._sync_curves = []
self._async_curves = []
for curve in curve_list:
self.remove_curve(curve.name())
if self.crosshair is not None:
@ -943,6 +941,7 @@ class Waveform(PlotBase):
self.on_async_readback,
MessageEndpoints.device_async_readback(self.scan_id, curve.name()),
)
curve.rpc_register.remove_rpc(curve)
# Remove itself from the DAP summary only for side panels
if (
@ -1330,7 +1329,9 @@ class Waveform(PlotBase):
# find the device curve
parent_curve = self._find_curve_by_label(parent_label)
if parent_curve is None:
logger.warning(f"No device curve found for DAP curve '{dap_curve.name()}'!")
logger.warning(
f"No device curve found for DAP curve '{dap_curve.name()}'!"
) # TODO triggerd when DAP curve is removed from the curve dialog, why?
continue
x_data, y_data = parent_curve.get_data()
@ -1565,7 +1566,6 @@ class Waveform(PlotBase):
found_sync = True
else:
logger.warning("Device {dev_name} not found in readout priority list.")
# Determine the mode of the scan
if found_async and found_sync:
mode = "mixed"
@ -1599,18 +1599,31 @@ class Waveform(PlotBase):
logger.warning(f"Neither scan_id or scan_number was provided, fetching the latest scan")
scan_index = -1
if scan_index is not None:
if len(self.client.history) == 0:
logger.info("No scans executed so far. Skipping scan history update.")
return
self.scan_item = self.client.history[scan_index]
metadata = self.scan_item.metadata
self.scan_id = metadata["bec"]["scan_id"]
else:
if scan_index is None:
self.scan_id = scan_id
self.scan_item = self.client.history.get_by_scan_id(scan_id)
self._emit_signal_update()
return
if scan_index == -1:
scan_item = self.client.queue.scan_storage.current_scan
if scan_item is not None:
self.scan_item = scan_item
self.scan_id = scan_item.scan_id
self._emit_signal_update()
return
if len(self.client.history) == 0:
logger.info("No scans executed so far. Skipping scan history update.")
return
self.scan_item = self.client.history[scan_index]
metadata = self.scan_item.metadata
self.scan_id = metadata["bec"]["scan_id"]
self._emit_signal_update()
def _emit_signal_update(self):
self._categorise_device_curves()
self.setup_dap_for_scan()
@ -1733,9 +1746,11 @@ class Waveform(PlotBase):
self.clear_all()
if self.curve_settings_dialog is not None:
self.curve_settings_dialog.close()
self.curve_settings_dialog.deleteLater()
self.curve_settings_dialog = None
if self.dap_summary_dialog is not None:
self.dap_summary_dialog.close()
self.dap_summary_dialog.deleteLater()
self.dap_summary_dialog = None
super().cleanup()

View File

@ -29,8 +29,6 @@ def test_axis_settings_init(axis_settings_fixture):
assert axis_settings.layout.count() == 1 # scroll area
# Check the target
assert axis_settings.target_widget == plot_base
# Check the object name
assert axis_settings.objectName() == "AxisSettings"
def test_change_ui_updates_plot_base(axis_settings_fixture, qtbot):

View File

@ -33,8 +33,6 @@ def test_curve_setting_init(curve_setting_fixture):
"""
curve_setting, wf = curve_setting_fixture
# Basic checks
assert curve_setting.objectName() == "CurveSetting"
# The layout should be QVBoxLayout
assert isinstance(curve_setting.layout, QVBoxLayout)

View File

@ -134,12 +134,6 @@ def test_curve_access_pattern(qtbot, mocked_client):
assert wf.get_curve(0) == c1
assert wf.get_curve(1) == c2
# Check that the curve is accessible by label
assert wf["bpm4i-bpm4i"] == c1
assert wf["bpm3a-bpm3a"] == c2
assert wf[0] == c1
assert wf[1] == c2
assert wf.curves[0] == c1
assert wf.curves[1] == c2