mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-30 16:21:28 +01:00
fix(dock_area): bind deferred splitter callbacks and stop busy polling
This commit is contained in:
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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user