1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-01-01 03:21:19 +01:00

feat(advanced_dock_area): floating docks restore with relative geometry

This commit is contained in:
2025-11-21 13:47:28 +01:00
committed by Klaus Wakonig
parent d090f8f7e5
commit c2780d629c
5 changed files with 339 additions and 5 deletions

View File

@@ -1352,6 +1352,7 @@ class DockAreaWidget(RPCBase):
floatable: "bool" = True,
movable: "bool" = True,
start_floating: "bool" = False,
floating_state: "Mapping[str, object] | None" = None,
where: "Literal['left', 'right', 'top', 'bottom'] | None" = None,
on_close: "Callable[[CDockWidget, QWidget], None] | None" = None,
tab_with: "CDockWidget | QWidget | str | None" = None,
@@ -1374,6 +1375,7 @@ class DockAreaWidget(RPCBase):
floatable(bool): Whether the dock is floatable.
movable(bool): Whether the dock is movable.
start_floating(bool): Whether to start the dock floating.
floating_state(Mapping | None): Optional floating geometry metadata to apply when floating.
where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when
``relative_to`` is provided without an explicit value).
on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget).
@@ -2901,6 +2903,7 @@ class MonacoDock(RPCBase):
floatable: "bool" = True,
movable: "bool" = True,
start_floating: "bool" = False,
floating_state: "Mapping[str, object] | None" = None,
where: "Literal['left', 'right', 'top', 'bottom'] | None" = None,
on_close: "Callable[[CDockWidget, QWidget], None] | None" = None,
tab_with: "CDockWidget | QWidget | str | None" = None,
@@ -2923,6 +2926,7 @@ class MonacoDock(RPCBase):
floatable(bool): Whether the dock is floatable.
movable(bool): Whether the dock is movable.
start_floating(bool): Whether to start the dock floating.
floating_state(Mapping | None): Optional floating geometry metadata to apply when floating.
where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when
``relative_to`` is provided without an explicit value).
on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget).

View File

@@ -551,6 +551,13 @@ class AdvancedDockArea(DockAreaWidget):
) or any(os.path.exists(path) for path in default_profile_candidates(name, namespace))
def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None:
"""
Write the current workspace snapshot to the provided settings object.
Args:
settings(QSettings): The settings object to write to.
save_preview(bool): Whether to save a screenshot preview.
"""
self.save_to_settings(settings, keys=PROFILE_STATE_KEYS)
self.state_manager.save_state(settings=settings)
write_manifest(settings, self.dock_list())
@@ -688,11 +695,20 @@ class AdvancedDockArea(DockAreaWidget):
if obj_name not in self.widget_map():
w = widget_handler.create_widget(widget_type=widget_class, parent=self)
w.setObjectName(obj_name)
floating_state = None
if item.get("floating"):
floating_state = {
"relative": item.get("floating_relative"),
"absolute": item.get("floating_absolute"),
"screen_name": item.get("floating_screen"),
}
self._make_dock(
w,
closable=item["closable"],
floatable=item["floatable"],
movable=item["movable"],
start_floating=item.get("floating", False),
floating_state=floating_state,
area=QtAds.DockWidgetArea.RightDockWidgetArea,
)

View File

@@ -4,10 +4,11 @@ import inspect
from dataclasses import dataclass
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, Qt, QTimer
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget
from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget
from shiboken6 import isValid
import bec_widgets.widgets.containers.qt_ads as QtAds
@@ -22,6 +23,8 @@ from bec_widgets.widgets.containers.qt_ads import (
CDockWidget,
)
logger = bec_logger.logger
class DockSettingsDialog(QDialog):
"""Generic settings editor shown from dock title bar actions."""
@@ -64,6 +67,7 @@ class DockAreaWidget(BECWidget, QWidget):
floatable: bool = True
movable: bool = True
start_floating: bool = False
floating_state: Mapping[str, Any] | None = None
area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea
on_close: Callable[[CDockWidget, QWidget], None] | None = None
tab_with: CDockWidget | None = None
@@ -258,6 +262,7 @@ class DockAreaWidget(BECWidget, QWidget):
movable: bool = True,
area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea,
start_floating: bool = False,
floating_state: Mapping[str, object] | None = None,
on_close: Callable[[CDockWidget, QWidget], None] | None = None,
tab_with: CDockWidget | None = None,
relative_to: CDockWidget | None = None,
@@ -276,6 +281,7 @@ class DockAreaWidget(BECWidget, QWidget):
movable(bool): Whether the dock can be moved.
area(QtAds.DockWidgetArea): Target dock area.
start_floating(bool): Whether the dock should start floating.
floating_state(Mapping | None): Optional geometry metadata to apply when floating.
on_close(Callable[[CDockWidget, QWidget], None] | None): Custom close handler.
tab_with(CDockWidget | None): Optional dock to tab with.
relative_to(CDockWidget | None): Optional dock to position relative to.
@@ -336,6 +342,8 @@ class DockAreaWidget(BECWidget, QWidget):
if start_floating and tab_with is None and not promote_central:
dock.setFloating()
if floating_state:
self._apply_floating_state_to_dock(dock, floating_state)
if resolved_icon is not None:
dock.setIcon(resolved_icon)
return dock
@@ -424,6 +432,7 @@ class DockAreaWidget(BECWidget, QWidget):
floatable: bool,
movable: bool,
start_floating: bool,
floating_state: Mapping[str, object] | None,
where: Literal["left", "right", "top", "bottom"] | None,
on_close: Callable[[CDockWidget, QWidget], None] | None,
tab_with: CDockWidget | QWidget | str | None,
@@ -444,6 +453,7 @@ class DockAreaWidget(BECWidget, QWidget):
floatable(bool): Whether the dock can be floated.
movable(bool): Whether the dock can be moved.
start_floating(bool): Whether the dock should start floating.
floating_state(Mapping | None): Optional floating geometry metadata.
where(Literal["left", "right", "top", "bottom"] | None): Target dock area.
on_close(Callable[[CDockWidget, QWidget], None] | None): Custom close handler.
tab_with(CDockWidget | QWidget | str | None): Optional dock to tab with.
@@ -489,6 +499,7 @@ class DockAreaWidget(BECWidget, QWidget):
floatable=floatable,
movable=movable,
start_floating=start_floating,
floating_state=floating_state,
area=target_area,
on_close=on_close,
tab_with=resolved_tab,
@@ -517,6 +528,7 @@ class DockAreaWidget(BECWidget, QWidget):
closable=spec.closable,
floatable=spec.floatable,
movable=spec.movable,
floating_state=spec.floating_state,
area=spec.area,
start_floating=spec.start_floating,
on_close=spec.on_close,
@@ -824,6 +836,126 @@ class DockAreaWidget(BECWidget, QWidget):
defaults[key] = value
return defaults
def _select_screen_for_entry(
self, entry: Mapping[str, object], container: QtAds.CFloatingDockContainer | None
):
"""
Pick the best target screen for a saved floating container.
Args:
entry(Mapping[str, object]): Floating window entry.
container(QtAds.CFloatingDockContainer | None): Optional container instance.
"""
screens = QApplication.screens() or []
try:
name = entry.get("screen_name") or ""
except Exception as exc:
logger.warning(f"Invalid screen_name in floating window entry: {exc}")
name = ""
if name:
for screen in screens:
try:
if screen.name() == name:
return screen
except Exception as exc:
logger.warning(f"Error checking screen name '{name}': {exc}")
continue
if container is not None and hasattr(container, "screen"):
screen = container.screen()
if screen is not None:
return screen
return screens[0] if screens else None
def _apply_saved_floating_geometry(
self, container: QtAds.CFloatingDockContainer, entry: Mapping[str, object]
) -> None:
"""
Resize/move a floating container using saved geometry information.
Args:
container(QtAds.CFloatingDockContainer): Target floating container.
entry(Mapping[str, object]): Floating window entry.
"""
abs_geom = entry.get("absolute") if isinstance(entry, Mapping) else None
if isinstance(abs_geom, Mapping):
try:
x = int(abs_geom.get("x"))
y = int(abs_geom.get("y"))
width = int(abs_geom.get("w"))
height = int(abs_geom.get("h"))
except Exception as exc:
logger.warning(f"Invalid absolute geometry in floating window entry: {exc}")
else:
if width > 0 and height > 0:
container.setGeometry(x, y, max(width, 50), max(height, 50))
return
rel = entry.get("relative") if isinstance(entry, Mapping) else None
if not isinstance(rel, Mapping):
return
try:
x_ratio = float(rel.get("x"))
y_ratio = float(rel.get("y"))
w_ratio = float(rel.get("w"))
h_ratio = float(rel.get("h"))
except Exception as exc:
logger.warning(f"Invalid relative geometry in floating window entry: {exc}")
return
screen = self._select_screen_for_entry(entry, container)
if screen is None:
return
geom = screen.availableGeometry()
screen_w = geom.width()
screen_h = geom.height()
if screen_w <= 0 or screen_h <= 0:
return
min_w = 120
min_h = 80
width = max(min_w, int(round(screen_w * max(w_ratio, 0.05))))
height = max(min_h, int(round(screen_h * max(h_ratio, 0.05))))
width = min(width, screen_w)
height = min(height, screen_h)
x = geom.left() + int(round(screen_w * x_ratio))
y = geom.top() + int(round(screen_h * y_ratio))
x = max(geom.left(), min(x, geom.left() + screen_w - width))
y = max(geom.top(), min(y, geom.top() + screen_h - height))
container.setGeometry(x, y, width, height)
def _apply_floating_state_to_dock(
self, dock: CDockWidget, state: Mapping[str, object], *, attempt: int = 0
) -> None:
"""
Apply saved floating geometry to a dock once its container exists.
Args:
dock(CDockWidget): Target dock widget.
state(Mapping[str, object]): Saved floating state.
attempt(int): Current attempt count for retries.
"""
if state is None:
return
def schedule(next_attempt: int):
QTimer.singleShot(
50, lambda: self._apply_floating_state_to_dock(dock, state, attempt=next_attempt)
)
container = dock.floatingDockContainer()
if container is None:
if attempt < 10:
schedule(attempt + 1)
return
entry = {
"relative": state.get("relative") if isinstance(state, Mapping) else None,
"absolute": state.get("absolute") if isinstance(state, Mapping) else None,
"screen_name": state.get("screen_name") if isinstance(state, Mapping) else None,
}
self._apply_saved_floating_geometry(container, entry)
def save_to_settings(
self,
settings: QSettings,
@@ -1083,6 +1215,7 @@ class DockAreaWidget(BECWidget, QWidget):
floatable: bool = True,
movable: bool = True,
start_floating: bool = False,
floating_state: Mapping[str, object] | None = None,
where: Literal["left", "right", "top", "bottom"] | None = None,
on_close: Callable[[CDockWidget, QWidget], None] | None = None,
tab_with: CDockWidget | QWidget | str | None = None,
@@ -1105,6 +1238,7 @@ class DockAreaWidget(BECWidget, QWidget):
floatable(bool): Whether the dock is floatable.
movable(bool): Whether the dock is movable.
start_floating(bool): Whether to start the dock floating.
floating_state(Mapping | None): Optional floating geometry metadata to apply when floating.
where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when
``relative_to`` is provided without an explicit value).
on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget).
@@ -1148,6 +1282,7 @@ class DockAreaWidget(BECWidget, QWidget):
floatable=floatable,
movable=movable,
start_floating=start_floating,
floating_state=floating_state,
where=where,
on_close=on_close,
tab_with=tab_with,
@@ -1173,6 +1308,7 @@ class DockAreaWidget(BECWidget, QWidget):
floatable=floatable,
movable=movable,
start_floating=start_floating,
floating_state=floating_state,
where=where,
on_close=on_close,
tab_with=tab_with,
@@ -1187,13 +1323,29 @@ class DockAreaWidget(BECWidget, QWidget):
dock = self._create_dock_from_spec(spec)
return dock if return_dock else widget
def _iter_all_docks(self) -> list[CDockWidget]:
"""Return all docks, including those hosted in floating containers."""
docks = list(self.dock_manager.dockWidgets())
seen = {id(d) for d in docks}
for container in self.dock_manager.floatingWidgets():
if container is None:
continue
for dock in container.dockWidgets():
if dock is None:
continue
if id(dock) in seen:
continue
docks.append(dock)
seen.add(id(dock))
return docks
def dock_map(self) -> dict[str, CDockWidget]:
"""Return the dock widgets map as dictionary with names as keys."""
return self.dock_manager.dockWidgetsMap()
return {dock.objectName(): dock for dock in self._iter_all_docks() if dock.objectName()}
def dock_list(self) -> list[CDockWidget]:
"""Return the list of dock widgets."""
return self.dock_manager.dockWidgets()
return self._iter_all_docks()
def widget_map(self) -> dict[str, QWidget]:
"""Return a dictionary mapping widget names to their corresponding widgets."""

View File

@@ -22,6 +22,7 @@ from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from pydantic import BaseModel, Field
from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.containers.qt_ads import CDockWidget
@@ -655,8 +656,44 @@ def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None:
settings(QSettings): Settings object to write to.
docks(list[CDockWidget]): List of dock widgets to serialize.
"""
settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(docks))
for i, dock in enumerate(docks):
def _floating_snapshot(dock: CDockWidget) -> dict | None:
if not hasattr(dock, "isFloating") or not dock.isFloating():
return None
container = dock.floatingDockContainer() if hasattr(dock, "floatingDockContainer") else None
if container is None:
return None
geom = container.frameGeometry()
if geom.isNull():
return None
absolute = {"x": geom.x(), "y": geom.y(), "w": geom.width(), "h": geom.height()}
screen = container.screen() if hasattr(container, "screen") else None
if screen is None:
screen = QApplication.screenAt(geom.center()) if QApplication.instance() else None
screen_name = ""
relative = None
if screen is not None:
if hasattr(screen, "name"):
try:
screen_name = screen.name()
except Exception:
screen_name = ""
avail = screen.availableGeometry()
width = max(1, avail.width())
height = max(1, avail.height())
relative = {
"x": (geom.left() - avail.left()) / float(width),
"y": (geom.top() - avail.top()) / float(height),
"w": geom.width() / float(width),
"h": geom.height() / float(height),
}
return {"screen_name": screen_name, "relative": relative, "absolute": absolute}
ordered_docks = [dock for dock in docks if dock.isFloating()] + [
dock for dock in docks if not dock.isFloating()
]
settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(ordered_docks))
for i, dock in enumerate(ordered_docks):
settings.setArrayIndex(i)
w = dock.widget()
settings.setValue("object_name", w.objectName())
@@ -664,6 +701,32 @@ def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None:
settings.setValue("closable", getattr(dock, "_default_closable", True))
settings.setValue("floatable", getattr(dock, "_default_floatable", True))
settings.setValue("movable", getattr(dock, "_default_movable", True))
is_floating = bool(dock.isFloating())
settings.setValue("floating", is_floating)
if is_floating:
snapshot = _floating_snapshot(dock)
if snapshot:
relative = snapshot.get("relative") or {}
absolute = snapshot.get("absolute") or {}
settings.setValue("floating_screen", snapshot.get("screen_name", ""))
settings.setValue("floating_rel_x", relative.get("x", 0.0))
settings.setValue("floating_rel_y", relative.get("y", 0.0))
settings.setValue("floating_rel_w", relative.get("w", 0.0))
settings.setValue("floating_rel_h", relative.get("h", 0.0))
settings.setValue("floating_abs_x", absolute.get("x", 0))
settings.setValue("floating_abs_y", absolute.get("y", 0))
settings.setValue("floating_abs_w", absolute.get("w", 0))
settings.setValue("floating_abs_h", absolute.get("h", 0))
else:
settings.setValue("floating_screen", "")
settings.setValue("floating_rel_x", 0.0)
settings.setValue("floating_rel_y", 0.0)
settings.setValue("floating_rel_w", 0.0)
settings.setValue("floating_rel_h", 0.0)
settings.setValue("floating_abs_x", 0)
settings.setValue("floating_abs_y", 0)
settings.setValue("floating_abs_w", 0)
settings.setValue("floating_abs_h", 0)
settings.endArray()
@@ -681,6 +744,22 @@ def read_manifest(settings: QSettings) -> list[dict]:
count = settings.beginReadArray(SETTINGS_KEYS["manifest"])
for i in range(count):
settings.setArrayIndex(i)
floating = settings.value("floating", False, type=bool)
rel = {
"x": float(settings.value("floating_rel_x", 0.0)),
"y": float(settings.value("floating_rel_y", 0.0)),
"w": float(settings.value("floating_rel_w", 0.0)),
"h": float(settings.value("floating_rel_h", 0.0)),
}
abs_geom = {
"x": int(settings.value("floating_abs_x", 0)),
"y": int(settings.value("floating_abs_y", 0)),
"w": int(settings.value("floating_abs_w", 0)),
"h": int(settings.value("floating_abs_h", 0)),
}
if not floating:
rel = None
abs_geom = None
items.append(
{
"object_name": settings.value("object_name"),
@@ -688,6 +767,10 @@ def read_manifest(settings: QSettings) -> list[dict]:
"closable": settings.value("closable", type=bool),
"floatable": settings.value("floatable", type=bool),
"movable": settings.value("movable", type=bool),
"floating": floating,
"floating_screen": settings.value("floating_screen", ""),
"floating_relative": rel,
"floating_absolute": abs_geom,
}
)
settings.endArray()

View File

@@ -21,6 +21,7 @@ from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import (
DockSettingsDialog,
)
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
SETTINGS_KEYS,
default_profile_path,
get_profile_info,
is_profile_read_only,
@@ -249,6 +250,31 @@ class TestBasicDockArea:
basic_dock_area.delete_all()
assert basic_dock_area.dock_list() == []
def test_manifest_serialization_includes_floating_geometry(
self, basic_dock_area, qtbot, tmp_path
):
anchored = QWidget(parent=basic_dock_area)
anchored.setObjectName("anchored_widget")
floating = QWidget(parent=basic_dock_area)
floating.setObjectName("floating_widget")
basic_dock_area.new(anchored, return_dock=True)
dock_floating = basic_dock_area.new(floating, return_dock=True, start_floating=True)
qtbot.waitUntil(lambda: dock_floating.isFloating(), timeout=2000)
settings_path = tmp_path / "manifest.ini"
settings = QSettings(str(settings_path), QSettings.IniFormat)
write_manifest(settings, basic_dock_area.dock_list())
settings.sync()
manifest_entries = read_manifest(settings)
assert len(manifest_entries) == 2
assert manifest_entries[0]["object_name"] == "floating_widget"
assert manifest_entries[0]["floating"] is True
assert manifest_entries[0]["floating_relative"] is not None
assert manifest_entries[1]["object_name"] == "anchored_widget"
assert manifest_entries[1]["floating"] is False
def test_splitter_weight_coercion_supports_aliases(self, basic_dock_area):
weights = {"default": 0.5, "left": 2, "center": 3, "right": 4}
@@ -837,6 +863,59 @@ class TestToolbarFunctionality:
final_floating = len(advanced_dock_area.dock_manager.floatingWidgets())
assert final_floating <= initial_floating
def test_load_profile_restores_floating_dock(self, advanced_dock_area, qtbot):
helper = profile_helper(advanced_dock_area)
settings = helper.open_user("floating_profile")
settings.clear()
settings.setValue("profile/created_at", "2025-11-23T00:00:00Z")
settings.beginWriteArray(SETTINGS_KEYS["manifest"], 2)
# Floating entry
settings.setArrayIndex(0)
settings.setValue("object_name", "FloatingWaveform")
settings.setValue("widget_class", "DarkModeButton")
settings.setValue("closable", True)
settings.setValue("floatable", True)
settings.setValue("movable", True)
settings.setValue("floating", True)
settings.setValue("floating_screen", "")
settings.setValue("floating_rel_x", 0.1)
settings.setValue("floating_rel_y", 0.1)
settings.setValue("floating_rel_w", 0.2)
settings.setValue("floating_rel_h", 0.2)
settings.setValue("floating_abs_x", 50)
settings.setValue("floating_abs_y", 50)
settings.setValue("floating_abs_w", 200)
settings.setValue("floating_abs_h", 150)
# Anchored entry
settings.setArrayIndex(1)
settings.setValue("object_name", "EmbeddedWaveform")
settings.setValue("widget_class", "DarkModeButton")
settings.setValue("closable", True)
settings.setValue("floatable", True)
settings.setValue("movable", True)
settings.setValue("floating", False)
settings.setValue("floating_screen", "")
settings.setValue("floating_rel_x", 0.0)
settings.setValue("floating_rel_y", 0.0)
settings.setValue("floating_rel_w", 0.0)
settings.setValue("floating_rel_h", 0.0)
settings.setValue("floating_abs_x", 0)
settings.setValue("floating_abs_y", 0)
settings.setValue("floating_abs_w", 0)
settings.setValue("floating_abs_h", 0)
settings.endArray()
settings.sync()
advanced_dock_area.delete_all()
advanced_dock_area.load_profile("floating_profile")
qtbot.waitUntil(lambda: "FloatingWaveform" in advanced_dock_area.dock_map(), timeout=3000)
floating_dock = advanced_dock_area.dock_map()["FloatingWaveform"]
assert floating_dock.isFloating()
def test_screenshot_action(self, advanced_dock_area, tmpdir):
"""Test screenshot toolbar action."""
# Create a test screenshot file path in tmpdir