From c2780d629ca0bc2cfb3fbdbe31784320240f376f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 21 Nov 2025 13:47:28 +0100 Subject: [PATCH] feat(advanced_dock_area): floating docks restore with relative geometry --- bec_widgets/cli/client.py | 4 + .../advanced_dock_area/advanced_dock_area.py | 16 ++ .../advanced_dock_area/basic_dock_area.py | 158 +++++++++++++++++- .../advanced_dock_area/profile_utils.py | 87 +++++++++- tests/unit_tests/test_advanced_dock_area.py | 79 +++++++++ 5 files changed, 339 insertions(+), 5 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index c8339776..2052c9b9 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -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). diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 83b88a7e..07e878ee 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -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, ) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py index 7334bd85..5620e7d1 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -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.""" diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index f09f5c54..87f03968 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -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() diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index 212494b2..f108d39b 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -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