fix(dock_area): bind deferred splitter callbacks and stop busy polling

This commit is contained in:
wyzula_j authored and Jan Wyzula committed 2026-10-16 08:48:29 +02:00
1 parent 6fd49fa352
commit 7ce6bf2a5a
2 files changed
+229 -5

No files matched your search

@@ -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()
+159 -1
View File
@@ -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")