diff --git a/bec_widgets/widgets/containers/dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/dock_area/basic_dock_area.py index 1c73651d..848eeabd 100644 --- a/bec_widgets/widgets/containers/dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/basic_dock_area.py @@ -6,7 +6,7 @@ from typing import Any, Callable, Literal, Mapping, Sequence, cast from bec_lib import bec_logger from bec_qthemes import material_icon -from qtpy.QtCore import QByteArray, QSettings, QSize, Qt, QTimer +from qtpy.QtCore import QByteArray, QEvent, QObject, QSettings, QSize, Qt, QTimer from qtpy.QtGui import QIcon from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget from shiboken6 import isValid @@ -39,6 +39,60 @@ class DockSettingsDialog(QDialog): layout.addWidget(self.prop_editor) +class _ResizeWatcher(QObject): + """ + One-shot event filter that runs a callback after the watched widget was resized. + + Used to defer work that needs real geometry (e.g. splitter ratios) without polling the + event loop. The watcher is parented to the watched widget, so it dies with it. + """ + + def __init__(self, target: QWidget, callback: Callable[[], None]): + super().__init__(target) + self._callback = callback + target.installEventFilter(self) + + @classmethod + def watch(cls, target: QWidget, callback: Callable[[], None]) -> _ResizeWatcher: + """ + Run ``callback`` once after ``target`` is next resized, replacing a pending watcher. + + Args: + target(QWidget): Widget to watch. + callback(Callable[[], None]): Called (deferred, bound to ``target``) after the resize. + + Returns: + _ResizeWatcher: The new watcher. + """ + cls.cancel_pending(target) + return cls(target, callback) + + @classmethod + def cancel_pending(cls, target: QWidget) -> None: + """ + Cancel every watcher still waiting for a resize of ``target``. + + Args: + target(QWidget): Widget whose pending watchers are cancelled. + """ + for pending in target.findChildren(cls, options=Qt.FindChildOption.FindDirectChildrenOnly): + pending.cancel() + + def cancel(self) -> None: + """Stop watching and schedule the watcher for deletion.""" + target = self.parent() + if target is not None: + target.removeEventFilter(self) + self.deleteLater() + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if event.type() == QEvent.Type.Resize: + self.cancel() + # Deferred so the widget finishes its own resize handling first. + QTimer.singleShot(0, watched, self._callback) + return False + + class DockAreaWidget(BECWidget, QWidget): """ Lightweight dock area that exposes the core Qt ADS docking helpers without any @@ -690,6 +744,10 @@ class DockAreaWidget(BECWidget, QWidget): """ Apply weight ratios to a splitter once geometry is available. + The deferred callback is bound to the splitter as context object, so Qt drops it + if the splitter is deleted (e.g. by ADS while the layout changes) before it runs. + While the splitter has no usable size, the ratios wait for its next resize. + Args: splitter(QtAds.CDockSplitter): Target splitter. weights(Sequence[float] | Mapping[int | str, float] | None): Weight specification. @@ -711,7 +769,10 @@ class DockAreaWidget(BECWidget, QWidget): splitter.width() if orientation == Qt.Orientation.Horizontal else splitter.height() ) if total_px <= count: - QTimer.singleShot(0, apply) + # No usable geometry yet (e.g. the dock area is collapsed to zero size). Retry + # when the splitter is resized instead of re-arming a 0 ms timer, which would + # busy-poll the event loop for as long as the splitter stays empty. + _ResizeWatcher.watch(splitter, apply) return total = sum(ratios) @@ -726,7 +787,9 @@ class DockAreaWidget(BECWidget, QWidget): for i, weight in enumerate(ratios): splitter.setStretchFactor(i, max(1, int(round(weight * 100)))) - QTimer.singleShot(0, apply) + # A retry still waiting for an earlier request's resize must not overwrite these ratios. + _ResizeWatcher.cancel_pending(splitter) + QTimer.singleShot(0, splitter, apply) def _normalize_override_keys( self, @@ -1001,8 +1064,11 @@ class DockAreaWidget(BECWidget, QWidget): return def schedule(next_attempt: int): + # Bound to the dock: Qt drops the retry if the dock is deleted before it fires. QTimer.singleShot( - 50, lambda: self._apply_floating_state_to_dock(dock, state, attempt=next_attempt) + 50, + dock, + lambda: self._apply_floating_state_to_dock(dock, state, attempt=next_attempt), ) container = dock.floatingDockContainer() diff --git a/tests/unit_tests/test_dock_area.py b/tests/unit_tests/test_dock_area.py index fa91bbc1..3780f464 100644 --- a/tests/unit_tests/test_dock_area.py +++ b/tests/unit_tests/test_dock_area.py @@ -6,9 +6,10 @@ from unittest import mock from unittest.mock import MagicMock, patch import pytest +import shiboken6 from qtpy.QtCore import QSettings, Qt, QTimer from qtpy.QtGui import QPixmap -from qtpy.QtWidgets import QDialog, QMessageBox, QToolButton, QWidget +from qtpy.QtWidgets import QApplication, QDialog, QMessageBox, QSplitter, QToolButton, QWidget import bec_widgets.widgets.containers.dock_area.basic_dock_area as basic_dock_module import bec_widgets.widgets.containers.dock_area.dock_area as dock_area_module @@ -191,6 +192,27 @@ def basic_dock_area(qtbot, mocked_client): yield widget +@pytest.fixture +def collapsed_dock_area(qtbot, mocked_client): + """A two-column DockAreaWidget inside an outer QSplitter that collapses it to zero width.""" + outer = QSplitter() + qtbot.addWidget(outer) + outer.addWidget(QWidget()) + area = DockAreaWidget(parent=outer, client=mocked_client, title="Collapsed Dock Area") + outer.addWidget(area) + for name, where in (("left_panel", "left"), ("right_panel", "right")): + panel = QWidget(parent=area) + panel.setObjectName(name) + area.new(panel, where=where) + outer.resize(800, 600) + outer.show() + qtbot.waitExposed(outer) + root = area.dock_manager.dockContainers()[0].rootSplitter() + outer.setSizes([800, 0]) + qtbot.waitUntil(lambda: root.width() == 0) + yield outer, area, root + + class _NamespaceProfiles: """Helper that routes profile file helpers through a namespace.""" @@ -408,6 +430,9 @@ class TestBasicDockArea: def setStretchFactor(self, idx, value): self.stretch.append((idx, value)) + def findChildren(self, *_args, **_kwargs): + return [] + splitter = DummySplitter() basic_dock_area._schedule_splitter_weights(splitter, [1, 2, 1]) @@ -489,6 +514,139 @@ class TestBasicDockArea: assert vertical == [2, 3] assert overrides == {(): [9], (1, 0): [5, 5]} + def test_layout_ratios_skip_splitter_deleted_before_apply(self, basic_dock_area, qtbot): + """Deferred ratio callbacks must skip splitters deleted before they run (#1317).""" + panels = {} + for name in ("left", "top", "bottom"): + panel = QWidget(parent=basic_dock_area) + panel.setObjectName(name) + panels[name] = panel + basic_dock_area.new(panels["left"], where="left") + top_dock = basic_dock_area.new(panels["top"], where="right", return_dock=True) + basic_dock_area.new(panels["bottom"], where="bottom", relative_to=top_dock) + + root = basic_dock_area.dock_manager.dockContainers()[0].rootSplitter() + nested = [ + root.widget(idx) + for idx in range(root.count()) + if isinstance(root.widget(idx), basic_dock_module.QtAds.CDockSplitter) + ] + assert len(nested) == 1 + + basic_dock_area.set_layout_ratios(horizontal=[1, 3], vertical=[3, 1]) + # Removing a dock from the nested column makes ADS delete that splitter right away, + # before the deferred ratio callbacks had a chance to run. + basic_dock_area.delete("bottom") + assert not shiboken6.isValid(nested[0]) + + with qtbot.captureExceptions() as exceptions: + qtbot.wait(50) + + assert exceptions == [] + # The surviving root splitter still receives its ratios. + left_px, right_px = root.sizes() + assert right_px > 2 * left_px + + def test_splitter_weights_retry_skips_deleted_splitter(self, basic_dock_area, qtbot): + """A re-scheduled apply (geometry not ready yet) must not touch a deleted splitter.""" + splitter = basic_dock_module.QtAds.CDockSplitter(Qt.Orientation.Horizontal, basic_dock_area) + splitter.addWidget(QWidget()) + splitter.addWidget(QWidget()) + splitter.resize(0, 0) # no usable geometry -> apply() waits to be retried + + basic_dock_area._schedule_splitter_weights(splitter, [1, 2]) + QApplication.processEvents() # first apply() runs and re-schedules + shiboken6.delete(splitter) + + with qtbot.captureExceptions() as exceptions: + qtbot.wait(50) + + assert exceptions == [] + + def test_floating_state_retry_skips_deleted_dock(self, basic_dock_area, qtbot): + """The floating-geometry retry must not touch a dock deleted in the meantime.""" + panel = QWidget(parent=basic_dock_area) + panel.setObjectName("floating_retry") + dock = basic_dock_area.new(panel, return_dock=True) + assert dock.floatingDockContainer() is None # docked -> a retry gets scheduled + + basic_dock_area._apply_floating_state_to_dock( + dock, {"relative": {"x": 0.1, "y": 0.1, "w": 0.5, "h": 0.5}} + ) + basic_dock_area.delete("floating_retry") + + with qtbot.captureExceptions() as exceptions: + qtbot.wait(200) + + assert not shiboken6.isValid(dock) + assert exceptions == [] + + def test_layout_ratios_wait_for_geometry_without_busy_polling( + self, collapsed_dock_area, qtbot, monkeypatch + ): + """A dock area laid out at zero size must not re-arm a 0 ms timer until it gets geometry. + + The ratios are still applied once the area is given real space. + """ + outer, area, root = collapsed_dock_area + single_shots = [] + real_qtimer = basic_dock_module.QTimer + + class CountingQTimer(real_qtimer): + @staticmethod + def singleShot(*args): + single_shots.append(args[0]) + return real_qtimer.singleShot(*args) + + monkeypatch.setattr(basic_dock_module, "QTimer", CountingQTimer) + area.set_layout_ratios(horizontal=[1, 3]) + qtbot.wait(100) + scheduled_while_collapsed = len(single_shots) + + outer.setSizes([400, 400]) # give the dock area real width again + qtbot.waitUntil(lambda: root.width() > 0) + qtbot.waitUntil(lambda: root.sizes()[1] > 2 * root.sizes()[0]) + + # Without geometry the callback used to re-arm itself every event-loop pass (tens of + # thousands of times in 100 ms); now it waits for the splitter's resize instead. + assert scheduled_while_collapsed <= 2 + + def test_layout_ratios_latest_call_wins_while_collapsed(self, collapsed_dock_area, qtbot): + """Repeated ratio requests on a zero-size splitter keep one pending retry, the latest.""" + outer, area, root = collapsed_dock_area + area.set_layout_ratios(horizontal=[1, 3]) + qtbot.wait(20) + area.set_layout_ratios(horizontal=[3, 1]) + qtbot.wait(20) + + pending = root.findChildren( + basic_dock_module._ResizeWatcher, options=Qt.FindChildOption.FindDirectChildrenOnly + ) + assert len(pending) == 1 + + outer.setSizes([400, 400]) + qtbot.waitUntil(lambda: root.width() > 0) + qtbot.waitUntil(lambda: root.sizes()[0] > 2 * root.sizes()[1]) + qtbot.wait(20) + assert root.findChildren(basic_dock_module._ResizeWatcher) == [] + + def test_layout_ratios_newer_call_wins_over_pending_resize_retry( + self, collapsed_dock_area, qtbot + ): + """A retry waiting for a resize must not overwrite ratios requested after it.""" + outer, area, root = collapsed_dock_area + area.set_layout_ratios(horizontal=[1, 3]) + qtbot.wait(20) # applied while collapsed -> waits for the next resize + area.set_layout_ratios(horizontal=[3, 1]) + # Resizing before the newer request ran used to fire the older retry after it. + outer.setSizes([400, 400]) + qtbot.waitUntil(lambda: root.width() > 0) + qtbot.wait(50) + + left_px, right_px = root.sizes() + assert left_px > 2 * right_px + assert root.findChildren(basic_dock_module._ResizeWatcher) == [] + def test_first_dock_title_bar_preference_survives_second_dock(self, basic_dock_area): first = QWidget(parent=basic_dock_area) first.setObjectName("first_panel")