From fd8707d7f56f02a0321f6d454ba7ebd6251a8644 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 7 Oct 2026 14:51:11 +0200 Subject: [PATCH] refactor(widgets): move _call_later from PlotBase to BECWidget --- bec_widgets/utils/bec_widget.py | 24 ++++++++++- bec_widgets/widgets/plots/plot_base.py | 25 +---------- tests/unit_tests/test_bec_widget_lifecycle.py | 43 +++++++++++++++++++ tests/unit_tests/test_plot_base_next_gen.py | 17 -------- 4 files changed, 68 insertions(+), 41 deletions(-) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index d7beabb0..0d1e513b 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -2,13 +2,14 @@ from __future__ import annotations import os import tempfile +from collections.abc import Callable from datetime import datetime from functools import partial from typing import TYPE_CHECKING import shiboken6 from bec_lib.logger import bec_logger -from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt +from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt, QTimer from qtpy.QtGui import QFont, QPixmap from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget @@ -128,6 +129,27 @@ class BECWidget(BECConnector): self._update_overlay_theme(theme) self.apply_theme(theme) + def _call_later(self, msec: int, callback: Callable[[], object]) -> None: + """ + Call ``callback`` once after ``msec`` milliseconds, but only while the widget is in use. + + Use this instead of a bare ``QTimer.singleShot`` for deferred work that touches the + widget: the timer is bound to the widget, so the call is dropped if the widget is + deleted first, and it is skipped if the widget has been closed (cleaned up) but not + yet deleted. + + Args: + msec(int): Delay in milliseconds. + callback(Callable[[], object]): Called without arguments. + """ + + def _call_unless_cleaned_up(): + if self._destroyed: + return + callback() + + QTimer.singleShot(msec, self, _call_unless_cleaned_up) + def create_busy_state_widget(self) -> QWidget: """ Method to create a custom busy state widget to be shown in the busy overlay. diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index e5a12692..6c026073 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Iterable, Mapping from enum import Enum from typing import Literal, TypeAlias @@ -9,7 +9,7 @@ import pyqtgraph as pg from bec_lib import bec_logger from bec_lib.scan_data_container import ScanDataContainer from bec_lib.scan_items import ScanItem -from qtpy.QtCore import QPoint, QPointF, Qt, QTimer, Signal +from qtpy.QtCore import QPoint, QPointF, Qt, Signal from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget from bec_widgets.utils.bec_connector import ConnectionConfig @@ -1375,27 +1375,6 @@ class PlotBase(BECWidget, QWidget): self.crosshair.clear_markers() self.crosshair.update_markers() - def _call_later(self, msec: int, callback: Callable[[], object]) -> None: - """ - Call ``callback`` once after ``msec`` milliseconds, but only while the widget is in use. - - Use this instead of a bare ``QTimer.singleShot`` for deferred work that touches the - widget: the timer is bound to the widget, so the call is dropped if the widget is - deleted first, and it is skipped if the widget has been closed (cleaned up) but not - yet deleted. - - Args: - msec(int): Delay in milliseconds. - callback(Callable[[], object]): Called without arguments. - """ - - def _call_unless_cleaned_up(): - if self._destroyed: - return - callback() - - QTimer.singleShot(msec, self, _call_unless_cleaned_up) - def cleanup(self): self.toolbar.cleanup() self.unhook_crosshair(preserve_pin=False) diff --git a/tests/unit_tests/test_bec_widget_lifecycle.py b/tests/unit_tests/test_bec_widget_lifecycle.py index 9f0d6df7..7c2b184e 100644 --- a/tests/unit_tests/test_bec_widget_lifecycle.py +++ b/tests/unit_tests/test_bec_widget_lifecycle.py @@ -243,3 +243,46 @@ def test_parent_destruction_purges_dead_dispatcher_slots(qtbot, mocked_client, b for slot in bec_dispatcher._registered_slots.values(): owner = getattr(slot.cb, "__self__", None) assert not isinstance(owner, QObject) or shiboken6.isValid(owner) + + +def test_call_later_runs_only_while_widget_is_open(qtbot, mocked_client): + """ + _call_later runs the callback while the widget is open and skips it once the widget has + been closed (cleaned up) or deleted before the delay elapsed. + """ + open_widget = LifecycleWidget(client=mocked_client) + closed_widget = LifecycleWidget(client=mocked_client) + deleted_widget = LifecycleWidget(client=mocked_client) + calls = [] + + open_widget._call_later(10, lambda: calls.append("open")) + closed_widget._call_later(10, lambda: calls.append("closed")) + deleted_widget._call_later(10, lambda: calls.append("deleted")) + closed_widget.close() # closeEvent -> cleanup() before the callback is due + deleted_widget.deleteLater() + _flush_deferred_deletes(qtbot) + + qtbot.wait(100) + assert calls == ["open"] + + for widget in (open_widget, closed_widget): + widget.close() + widget.deleteLater() + _flush_deferred_deletes(qtbot) + + +def test_call_later_dropped_when_parent_destruction_deletes_widget(qtbot, mocked_client): + """ + Destroying a plain parent deletes the BECWidget child without a close event, so the + cleaned-up flag is never set; the timer is bound to the widget and Qt drops the call. + """ + parent = QWidget() + child = LifecycleWidget(parent=parent, client=mocked_client) + calls = [] + + child._call_later(10, lambda: calls.append("child")) + parent.deleteLater() + _flush_deferred_deletes(qtbot) + + qtbot.wait(100) + assert calls == [] diff --git a/tests/unit_tests/test_plot_base_next_gen.py b/tests/unit_tests/test_plot_base_next_gen.py index 2089028c..886e674a 100644 --- a/tests/unit_tests/test_plot_base_next_gen.py +++ b/tests/unit_tests/test_plot_base_next_gen.py @@ -792,20 +792,3 @@ def test_limits_accept_fractional_values(qtbot, mocked_client): pb.y_limits = (-1.75, 3.5) assert (pb.x_limits.x(), pb.x_limits.y()) == (0.5, 9.25) assert (pb.y_limits.x(), pb.y_limits.y()) == (-1.75, 3.5) - - -def test_call_later_skips_callback_once_widget_is_closed(qtbot, mocked_client): - """ - PlotBase._call_later runs the deferred callback while the widget is open and skips it - when the widget has been closed (cleaned up) before the delay elapsed. - """ - open_pb = create_widget(qtbot, PlotBase, client=mocked_client) - closed_pb = create_widget(qtbot, PlotBase, client=mocked_client) - calls = [] - - open_pb._call_later(10, lambda: calls.append("open")) - closed_pb._call_later(10, lambda: calls.append("closed")) - closed_pb.close() # closeEvent -> cleanup() before the callback is due - - qtbot.wait(100) - assert calls == ["open"]