diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index 13505087..e2765eb6 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -96,6 +96,19 @@ class WidgetStateManager: else: logger.warning("No filename or settings provided for saving state.") + def load_widget_state(self, widget: QWidget, settings: QSettings) -> None: + """ + Load the state of one widget below the managed root and of its children. + + Keys are resolved relative to the managed root exactly as `load_state` does, so this + restores the same values for *widget* as a full `load_state` would. + + Args: + widget(QWidget): Widget below the managed root. + settings(QSettings): The QSettings object to load the state from. + """ + self._load_widget_state_qsettings(widget, settings) + def _save_widget_state_qsettings( self, widget: QWidget, settings: QSettings, recursive: bool = True ): 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..1ad6edb5 100644 --- a/bec_widgets/widgets/containers/dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/basic_dock_area.py @@ -106,6 +106,8 @@ class DockAreaWidget(BECWidget, QWidget): self.dock_manager.setProperty("variant", variant) self._locked = False + # While > 0, per-dock parent normalization is skipped and done once at the end + self._dock_batch_depth = 0 self._default_add_direction = ( default_add_direction if default_add_direction in ("left", "right", "top", "bottom") @@ -147,7 +149,10 @@ class DockAreaWidget(BECWidget, QWidget): ).action action.setObjectName("dockSettingsAction") action.setToolTip("Dock settings") - action.triggered.connect(lambda: self._open_dock_settings_dialog(dock, widget)) + # Resolve the widget on trigger: profile loading swaps a placeholder for the real widget + action.triggered.connect( + lambda: self._open_dock_settings_dialog(dock, dock.widget() or widget) + ) existing = list(dock.titleBarActions()) existing.append(action) @@ -295,6 +300,8 @@ class DockAreaWidget(BECWidget, QWidget): """ Ensure each dock has a stable parent after tab switches, re-docking, or restore. """ + if self._dock_batch_depth > 0: + return self._connect_dock_area_parent_guards() for dock in self.dock_list(): if dock is None or not isValid(dock): @@ -352,13 +359,7 @@ class DockAreaWidget(BECWidget, QWidget): dock = CDockWidget(self.dock_manager, widget.objectName(), self) dock.setWidget(widget) - widget_min_size = widget.minimumSize() - widget_min_hint = widget.minimumSizeHint() - dock_min_size = QSize( - max(widget_min_size.width(), widget_min_hint.width()), - max(widget_min_size.height(), widget_min_hint.height()), - ) - dock.setMinimumSize(dock_min_size) + self._apply_dock_minimum_size(dock, widget) dock._dock_preferences = dict(dock_preferences or {}) dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetDeleteOnClose, True) dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True) @@ -369,17 +370,12 @@ class DockAreaWidget(BECWidget, QWidget): self._customize_dock(dock, widget) resolved_icon = self._resolve_dock_icon(widget, dock_icon, apply_widget_icon) - close_handler = self._resolve_close_handler(widget, on_close) - - def on_widget_destroyed(): - if not isValid(dock): - return - dock.closeDockWidget() - dock.deleteDockWidget() - - dock.closeRequested.connect(lambda: close_handler(dock)) - if hasattr(widget, "widget_removed"): - widget.widget_removed.connect(on_widget_destroyed) + # The close handler is resolved when the close is requested, so it follows the widget + # currently in the dock (profile loading swaps a placeholder for the real widget). + dock.closeRequested.connect( + lambda: self._resolve_close_handler(dock.widget() or widget, on_close)(dock) + ) + self._bind_widget_removed(dock, widget) dock.setMinimumSizeHintMode( CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidgetMinimumSize @@ -409,6 +405,67 @@ class DockAreaWidget(BECWidget, QWidget): self._normalize_all_dock_parents() return dock + @staticmethod + def _apply_dock_minimum_size(dock: CDockWidget, widget: QWidget) -> None: + """Size the dock so it never gets smaller than its widget allows.""" + widget_min_size = widget.minimumSize() + widget_min_hint = widget.minimumSizeHint() + dock.setMinimumSize( + QSize( + max(widget_min_size.width(), widget_min_hint.width()), + max(widget_min_size.height(), widget_min_hint.height()), + ) + ) + + @staticmethod + def _bind_widget_removed(dock: CDockWidget, widget: QWidget) -> None: + """Close the dock when its widget announces its own removal.""" + if not hasattr(widget, "widget_removed"): + return + + def on_widget_destroyed(): + if not isValid(dock) or dock.widget() is not widget: + return + dock.closeDockWidget() + dock.deleteDockWidget() + + widget.widget_removed.connect(on_widget_destroyed) + + def _replace_dock_widget( + self, dock: CDockWidget, widget: QWidget, *, apply_widget_icon: bool = True + ) -> QWidget | None: + """ + Put *widget* into an existing dock in place of its current content. + + The dock keeps its position, size, title and features, so the layout does not move. + + Args: + dock(CDockWidget): Dock whose content is replaced. + widget(QWidget): New content widget. + apply_widget_icon(bool): Whether to use the widget's ICON_NAME as dock icon. + + Returns: + QWidget | None: The previous content widget, now without a parent dock. + """ + previous = dock.takeWidget() + dock.setWidget(widget) + self._apply_dock_minimum_size(dock, widget) + self._bind_widget_removed(dock, widget) + icon = self._resolve_dock_icon(widget, None, apply_widget_icon) + if icon is not None: + dock.setIcon(icon) + return previous + + def _begin_dock_batch(self) -> None: + """Start adding many docks at once; parent normalization runs once in `_end_dock_batch`.""" + self._dock_batch_depth += 1 + + def _end_dock_batch(self) -> None: + """Finish a batch started with `_begin_dock_batch`.""" + self._dock_batch_depth = max(0, self._dock_batch_depth - 1) + if self._dock_batch_depth == 0: + self._normalize_all_dock_parents() + def _delete_dock(self, dock: CDockWidget) -> None: widget = dock.widget() if widget and isValid(widget): @@ -1477,7 +1534,17 @@ class DockAreaWidget(BECWidget, QWidget): @SafeSlot() def delete_all(self): """Delete all docks and their associated widgets.""" - for dock in self.dock_list(): + docks = self.dock_list() + # Take every widget out before removing any dock: removing a dock that still holds its + # widget makes Qt ADS re-lay out (and resize every plot in) the remaining docks each time. + for dock in docks: + if not isValid(dock): + continue + widget = dock.takeWidget() + if widget is not None and isValid(widget): + widget.close() + widget.deleteLater() + for dock in docks: self._delete_dock(dock) def cleanup(self): diff --git a/bec_widgets/widgets/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py index 8d7e5f54..c90800d1 100644 --- a/bec_widgets/widgets/containers/dock_area/dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -6,7 +6,7 @@ from typing import Literal, Mapping, Sequence import slugify from bec_lib import bec_logger -from qtpy.QtCore import Signal +from qtpy.QtCore import QEvent, Signal from qtpy.QtGui import QPixmap from qtpy.QtWidgets import ( QApplication, @@ -40,6 +40,13 @@ from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.widget_state_manager import WidgetStateManager from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget +from bec_widgets.widgets.containers.dock_area.profile_loading.common import ( + progressive_loading_enabled, +) +from bec_widgets.widgets.containers.dock_area.profile_loading.loader import ( + ProfileLoadJob, + create_progress, +) from bec_widgets.widgets.containers.dock_area.profile_utils import ( SETTINGS_KEYS, baseline_profile_candidates, @@ -124,6 +131,8 @@ class BECDockArea(DockAreaWidget): # Define a signal for mode changes mode_changed = Signal(str) profile_changed = Signal(str) + # Emitted when every dock of a loaded profile holds its real widget (or the load was cancelled) + profile_load_finished = Signal(str) def __init__( self, @@ -145,6 +154,9 @@ class BECDockArea(DockAreaWidget): self._auto_save_upon_exit = auto_save_upon_exit self._profile_management_enabled = enable_profile_management self._startup_profile = self._normalize_startup_profile(startup_profile) + self._profile_load_job: ProfileLoadJob | None = None + self._profile_load_progress = None + self._progressive_profile_load = False super().__init__( parent, default_add_direction=default_add_direction, title="BEC Dock Area", **kwargs ) @@ -248,7 +260,7 @@ class BECDockArea(DockAreaWidget): def _load_initial_profile(self, name: str) -> None: """Load the initial profile.""" - self.load_profile(name) + self.load_profile_progressive(name) if not self._empty_profile_active: self._set_workspace_combo_text_silent(name) @@ -675,6 +687,7 @@ class BECDockArea(DockAreaWidget): """ self.save_to_settings(settings, keys=PROFILE_STATE_KEYS) self.state_manager.save_state(settings=settings) + self._copy_pending_widget_state(settings) write_manifest(settings, self.dock_list()) if save_preview: ba = self.screenshot_bytes() @@ -938,6 +951,11 @@ class BECDockArea(DockAreaWidget): self.save_profile(name, show_dialog=False, quick_select=True) return + if progressive_loading_enabled(): + self._rebuild_profile(settings, name, namespace) + return + + # Previous blocking pipeline, kept for comparison (BEC_PROFILE_LOADING=legacy) # Clear existing docks and remove all widgets self.delete_all() @@ -971,6 +989,122 @@ class BECDockArea(DockAreaWidget): self._finalize_profile_change(name, namespace) + @SafeSlot(str) + @SafeSlot(str, bool) + def load_profile_progressive(self, name: str | None = None, restore_baseline: bool = False): + """ + Load a workspace profile without blocking the GUI. + + The saved layout appears at once with a skeleton in every dock, then the widgets are built + one per event-loop pass while a progress card offers to cancel. Used by the toolbar, the + workspace manager and startup; `load_profile` keeps returning only once all widgets exist. + + Args: + name (str | None): The name of the profile to load. If None, prompts the user. + restore_baseline (bool): If True, restore the runtime copy from the baseline first. + """ + self._progressive_profile_load = True + try: + self.load_profile(name, restore_baseline) + finally: + self._progressive_profile_load = False + + @property + def profile_load_in_progress(self) -> bool: + """True while docks of the current profile are still being filled.""" + job = self._profile_load_job + return job is not None and job.is_running + + @SafeSlot() + def cancel_profile_load(self) -> None: + """Stop filling docks; unbuilt docks keep a "Load now" button.""" + if self._profile_load_job is not None: + self._profile_load_job.cancel() + + def _rebuild_profile(self, settings, name: str, namespace: str | None) -> None: + """ + Replace the current docks with the profile in *settings*. + + Builds placeholder docks and restores the layout first, then fills the docks either in + the background (`load_profile_progressive`) or before returning (`load_profile`). + """ + self._discard_profile_load_job() + self.setUpdatesEnabled(False) + try: + self.delete_all() + job = ProfileLoadJob( + self, settings, read_manifest(settings), name, dict(PROFILE_STATE_KEYS) + ) + self._profile_load_job = job + job.build_skeleton() + self._set_editable(self._editable) + finally: + self.setUpdatesEnabled(True) + + job.finished.connect(lambda _completed, n=name: self.profile_load_finished.emit(n)) + + if self._progressive_profile_load: + # The profile is active from here on: a snapshot taken mid-load keeps unbuilt docks + self._finalize_profile_change(name, namespace) + progress = self._ensure_profile_load_progress() + job.progress.connect(progress.update_progress) + job.finished.connect(lambda _completed: progress.finish()) + progress.begin(name, job.total) + job.start() + return + + visible = self.isVisible() + if visible: + # Show the restored layout before the widgets are built + self.repaint() + job.run_to_completion(paint=visible) + self._finalize_profile_change(name, namespace) + + def _ensure_profile_load_progress(self): + if self._profile_load_progress is None: + self._profile_load_progress = create_progress(self.dock_manager) + self._profile_load_progress.cancel_requested.connect(self.cancel_profile_load) + self.dock_manager.installEventFilter(self) + return self._profile_load_progress + + def _discard_profile_load_job(self) -> None: + """Stop and drop the current load job before its docks are replaced.""" + job = self._profile_load_job + self._profile_load_job = None + if job is None: + return + job.cancel() + job.deleteLater() + + def _copy_pending_widget_state(self, settings) -> None: + """ + Keep the saved state of widgets that are not built yet when writing a snapshot. + + Placeholders do not take part in the state manager, so their widgets' entries are copied + from the profile being loaded; without this, saving mid-load would drop their settings. + """ + job = self._profile_load_job + if job is None: + return + names = {p.profile_object_name for p in job.pending_placeholders()} + if not names: + return + source = job.settings + if source.fileName() == settings.fileName(): + # Same file: the entries are already there and the state manager does not clear them + return + for key in source.allKeys(): + group = key.split("/", 1)[0] + if names.intersection(group.split(".")): + settings.setValue(key, source.value(key)) + + def eventFilter(self, obj, event): # noqa: N802 - Qt API + """Keep the loading progress card in place when the dock manager is resized.""" + progress = self._profile_load_progress + if progress is not None and obj is self.dock_manager and event.type() == QEvent.Type.Resize: + progress.reposition() + return super().eventFilter(obj, event) + @SafeSlot() @SafeSlot(str) @SafeSlot(str, bool) @@ -1271,6 +1405,7 @@ class BECDockArea(DockAreaWidget): Cleanup the dock area. """ self.prepare_for_shutdown() + self._discard_profile_load_job() if self.manage_dialog is not None: self.manage_dialog.reject() self.manage_dialog = None diff --git a/bec_widgets/widgets/containers/dock_area/profile_loading/__init__.py b/bec_widgets/widgets/containers/dock_area/profile_loading/__init__.py new file mode 100644 index 00000000..47d5632e --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_loading/__init__.py @@ -0,0 +1 @@ +"""Progressive profile loading: skeleton docks that fill with their widgets one at a time.""" diff --git a/bec_widgets/widgets/containers/dock_area/profile_loading/common.py b/bec_widgets/widgets/containers/dock_area/profile_loading/common.py new file mode 100644 index 00000000..92f4e156 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_loading/common.py @@ -0,0 +1,119 @@ +""" +Shared pieces of the progressive profile loading UI: placeholder states, skeleton shapes per widget +type and the switch between the QWidget and QML variants. +""" + +from __future__ import annotations + +import os +import time +import zlib +from typing import Literal + +from qtpy.QtGui import QColor +from qtpy.QtWidgets import QApplication + +from bec_widgets.utils.colors import get_accent_colors + +PlaceholderState = Literal["queued", "loading", "paused", "failed"] +SkeletonKind = Literal["plot", "list", "form"] + +#: Environment variable selecting the loading UI: ``qwidget`` (default) or ``qml``. +LOADING_UI_ENV = "BEC_PROFILE_LOADING_UI" +#: Environment variable that switches profile loading back to the previous blocking pipeline. +LOADING_MODE_ENV = "BEC_PROFILE_LOADING" + +_PLOT_WIDGETS = { + "Waveform", + "MultiWaveform", + "ScatterWaveform", + "Image", + "MotorMap", + "Heatmap", + "PlotBase", +} +_LIST_WIDGETS = { + "BECQueue", + "DeviceBrowser", + "BECStatusBox", + "LogPanel", + "ScanHistory", + "AvailableDeviceResources", + "DeviceManagerView", + "BeamlineStateManager", +} + +STATE_TEXT: dict[str, str] = { + "queued": "Waiting", + "loading": "Loading", + "paused": "Not loaded", + "failed": "Could not load", +} + +#: One shimmer sweep across a skeleton, in seconds. +SHIMMER_PERIOD = 1.6 + + +def loading_ui_variant() -> Literal["qwidget", "qml"]: + """Return the configured loading UI variant.""" + value = os.environ.get(LOADING_UI_ENV, "qwidget").strip().lower() + return "qml" if value == "qml" else "qwidget" + + +def progressive_loading_enabled() -> bool: + """Return False when the previous blocking pipeline was requested.""" + return os.environ.get(LOADING_MODE_ENV, "").strip().lower() != "legacy" + + +def skeleton_kind(widget_class: str) -> SkeletonKind: + """Pick the skeleton shape that resembles *widget_class*.""" + if widget_class in _PLOT_WIDGETS: + return "plot" + if widget_class in _LIST_WIDGETS: + return "list" + return "form" + + +def skeleton_seed(name: str) -> int: + """Stable per-dock seed so the skeleton rows keep their shape between repaints.""" + return zlib.crc32(name.encode("utf-8")) + + +def shimmer_phase() -> float: + """Shared shimmer position in [0, 1); all skeletons shimmer in sync.""" + return (time.monotonic() % SHIMMER_PERIOD) / SHIMMER_PERIOD + + +def _mix(a: QColor, b: QColor, t: float) -> QColor: + return QColor( + round(a.red() + (b.red() - a.red()) * t), + round(a.green() + (b.green() - a.green()) * t), + round(a.blue() + (b.blue() - a.blue()) * t), + ) + + +def loading_palette() -> dict[str, QColor]: + """ + Colors for skeletons and the progress card, derived from the active application palette so + they follow the light and dark themes. + """ + app = QApplication.instance() + palette = app.palette() if app is not None else None + window = palette.window().color() if palette else QColor("#1e1f22") + text = palette.windowText().color() if palette else QColor("#e6e6e6") + dark = window.lightness() < 128 + accents = get_accent_colors() + accent = QColor(accents.default) if accents is not None else QColor("#3b82f6") + emergency = QColor(accents.emergency) if accents is not None else QColor("#d64545") + return { + "window": window, + "text": text, + "muted": _mix(window, text, 0.62), + "block": _mix(window, text, 0.09 if dark else 0.07), + "block_strong": _mix(window, text, 0.14 if dark else 0.11), + "shine": _mix(window, text, 0.2 if dark else 0.02), + "card": _mix(window, text, 0.06) if dark else QColor("#ffffff"), + "border": _mix(window, text, 0.16), + "accent": accent, + "danger": emergency, + } diff --git a/bec_widgets/widgets/containers/dock_area/profile_loading/loader.py b/bec_widgets/widgets/containers/dock_area/profile_loading/loader.py new file mode 100644 index 00000000..6ce29bc8 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_loading/loader.py @@ -0,0 +1,289 @@ +""" +Progressive profile loading. + +Loading a profile used to build every widget, add each one as a dock and then restore the Qt ADS +layout with the real widgets inside, all in one call on the GUI thread. Restoring the layout around +real widgets is the most expensive step, because every plot re-lays itself out as the docks move. + +`ProfileLoadJob` splits the work: + +1. Skeleton: one lightweight placeholder dock per manifest entry, then the saved layout is restored + around them. This is cheap and puts the final layout on screen right away. +2. Fill: the real widgets are built one per event-loop pass and swapped into their docks in place, + so the GUI keeps painting and responding between widgets. Visible docks fill first. + +A job can also run to completion synchronously (for scripted and RPC calls that expect the +widgets to exist when `load_profile` returns). +""" + +# The job is the dock area's own helper and drives its private dock-building methods. +# pylint: disable=protected-access +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from bec_lib import bec_logger +from qtpy.QtCore import QObject, QSettings, QTimer, Signal +from shiboken6 import isValid + +import bec_widgets.widgets.containers.qt_ads as QtAds +from bec_widgets.utils.plugin_utils import get_rpc_widget +from bec_widgets.utils.rpc_register import RPCRegister +from bec_widgets.utils.rpc_widget_handler import widget_handler +from bec_widgets.widgets.containers.dock_area.profile_loading.common import loading_ui_variant +from bec_widgets.widgets.containers.dock_area.profile_loading.placeholder import ( + DockPlaceholder, + SkeletonPlaceholder, +) +from bec_widgets.widgets.containers.dock_area.profile_loading.progress import ( + ProfileLoadProgress, + ProfileLoadProgressBase, +) + +if TYPE_CHECKING: # pragma: no cover + from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea + +logger = bec_logger.logger + + +def _widget_icon_name(widget_class: str) -> str | None: + try: + cls = get_rpc_widget(widget_class, raise_on_missing=False) + # A broken plugin import must not stop the skeleton + except Exception: # pragma: no cover # pylint: disable=broad-exception-caught + return None + return getattr(cls, "ICON_NAME", None) if cls is not None else None + + +def create_placeholder(widget_class: str, object_name: str, parent=None) -> DockPlaceholder: + """Create a placeholder in the configured UI variant.""" + icon_name = _widget_icon_name(widget_class) + if loading_ui_variant() == "qml": + from bec_widgets.widgets.containers.dock_area.profile_loading.qml_variant import ( + QmlSkeletonPlaceholder, + ) + + return QmlSkeletonPlaceholder(widget_class, object_name, icon_name, parent) + return SkeletonPlaceholder(widget_class, object_name, icon_name, parent) + + +def create_progress(parent) -> ProfileLoadProgressBase: + """Create the progress card in the configured UI variant.""" + if loading_ui_variant() == "qml": + from bec_widgets.widgets.containers.dock_area.profile_loading.qml_variant import ( + QmlProfileLoadProgress, + ) + + return QmlProfileLoadProgress(parent) + return ProfileLoadProgress(parent) + + +class ProfileLoadJob(QObject): + """ + Build a profile's docks as placeholders, restore the layout, then fill the docks. + + Args: + area(BECDockArea): Dock area to load into; it must be empty. + settings(QSettings): Profile settings; kept open until the job is gone. + items(list[dict]): Manifest entries from `read_manifest`. + profile_name(str): Profile name, for progress reporting. + state_keys(dict): Settings keys passed to `load_from_settings`. + """ + + progress = Signal(int, int, str) + finished = Signal(bool) + + def __init__( + self, + area: "BECDockArea", + settings: QSettings, + items: list[dict[str, Any]], + profile_name: str, + state_keys: dict[str, str | None], + ): + super().__init__(area) + self.area = area + self.settings = settings + self.profile_name = profile_name + self._items = items + self._state_keys = state_keys + self._queue: list[str] = [] + self._done = 0 + self._running = False + self._timer = QTimer(self) + self._timer.setSingleShot(True) + self._timer.setInterval(0) + self._timer.timeout.connect(self._step) + + # Public API ------------------------------------------------------------------------------- + + @property + def total(self) -> int: + """Number of docks in the profile.""" + return len(self._items) + + @property + def is_running(self) -> bool: + """True while docks are being filled in the background.""" + return self._running + + def build_skeleton(self) -> None: + """Create the placeholder docks and restore the saved layout around them.""" + area = self.area + area._begin_dock_batch() + try: + for item in self._items: + obj_name = item["object_name"] + if obj_name in area.dock_map(): + continue + placeholder = create_placeholder(item["widget_class"], obj_name, parent=area) + placeholder.load_requested.connect(self.fill) + 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"), + } + area._make_dock( + placeholder, + closable=item["closable"], + floatable=item["floatable"], + movable=item["movable"], + start_floating=item.get("floating", False), + floating_state=floating_state, + area=QtAds.DockWidgetArea.RightDockWidgetArea, + ) + area.load_from_settings(self.settings, keys=self._state_keys) + finally: + area._end_dock_batch() + # Restores the dock area's own properties; placeholders opt out of the state manager + area.state_manager.load_state(settings=self.settings) + self._queue = self._fill_order() + + def start(self) -> None: + """Fill the docks one per event-loop pass.""" + self._running = True + self.progress.emit(self._done, self.total, self._next_label()) + self._timer.start() + + def run_to_completion(self, paint: bool = False) -> None: + """ + Fill every remaining dock now, without returning to the event loop. + + Args: + paint(bool): Repaint each dock right after it is filled, so a visible dock area shows + the docks appearing even though the event loop is blocked. + """ + self._timer.stop() + self._running = True + with RPCRegister.delayed_broadcast(): + while self._queue: + dock = self.fill(self._queue[0], emit=False) + if paint and dock is not None and dock.isVisible(): + dock.repaint() + self._complete() + + def cancel(self) -> None: + """Stop filling; docks that are not built yet offer a "Load now" button.""" + if not self._running: + return + self._timer.stop() + self._running = False + for obj_name in self._queue: + placeholder = self._placeholder(obj_name) + if placeholder is not None: + placeholder.set_state("paused") + self.finished.emit(False) + + def pending_placeholders(self) -> list[DockPlaceholder]: + """Placeholders that still stand in for unbuilt widgets.""" + return [ + dock.widget() + for dock in self.area.dock_list() + if isValid(dock) and isinstance(dock.widget(), DockPlaceholder) + ] + + def fill(self, obj_name: str, emit: bool = True): + """ + Build the real widget for *obj_name* and swap it into its dock. + + Args: + obj_name(str): Object name of the dock to fill. + emit(bool): Emit progress after filling. + + Returns: + CDockWidget | None: The filled dock, or None if there was nothing to fill. + """ + if obj_name in self._queue: + self._queue.remove(obj_name) + dock = self.area.dock_map().get(obj_name) + placeholder = self._placeholder(obj_name) + if dock is None or placeholder is None: + return None + placeholder.set_state("loading") + try: + widget = widget_handler.create_widget( + widget_type=placeholder.profile_widget_class, parent=self.area + ) + widget.setObjectName(obj_name) + except Exception as exc: # pylint: disable=broad-exception-caught + # One broken widget must not abort the whole profile + logger.exception( + f"Could not create '{placeholder.profile_widget_class}' for {obj_name}" + ) + placeholder.set_state("failed", str(exc) or exc.__class__.__name__) + return None + self.area._replace_dock_widget(dock, widget) + placeholder.close() + placeholder.deleteLater() + self.area.state_manager.load_widget_state(widget, self.settings) + self._done += 1 + if emit: + self.progress.emit(self._done, self.total, self._next_label()) + return dock + + # Internals ------------------------------------------------------------------------------- + + def _placeholder(self, obj_name: str) -> DockPlaceholder | None: + dock = self.area.dock_map().get(obj_name) + if dock is None or not isValid(dock): + return None + widget = dock.widget() + return widget if isinstance(widget, DockPlaceholder) and isValid(widget) else None + + def _fill_order(self) -> list[str]: + """Docks the user can see first, then hidden tabs and closed docks.""" + visible, hidden = [], [] + for item in self._items: + obj_name = item["object_name"] + placeholder = self._placeholder(obj_name) + if placeholder is None: + continue + dock = self.area.dock_map()[obj_name] + shown = not dock.isClosed() and dock.isCurrentTab() + (visible if shown else hidden).append(obj_name) + return visible + hidden + + def _next_label(self) -> str: + placeholder = self._placeholder(self._queue[0]) if self._queue else None + return placeholder.profile_widget_class if placeholder is not None else "" + + def _step(self) -> None: + if not self._running: + return + if not self._queue: + self._complete() + return + with RPCRegister.delayed_broadcast(): + self.fill(self._queue[0]) + # Mark the next dock now, so it paints as loading before its widget blocks the loop + upcoming = self._placeholder(self._queue[0]) if self._queue else None + if upcoming is not None: + upcoming.set_state("loading") + self._timer.start() + + def _complete(self) -> None: + self._running = False + self.progress.emit(self._done, self.total, "") + self.finished.emit(True) diff --git a/bec_widgets/widgets/containers/dock_area/profile_loading/placeholder.py b/bec_widgets/widgets/containers/dock_area/profile_loading/placeholder.py new file mode 100644 index 00000000..d3411f86 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_loading/placeholder.py @@ -0,0 +1,335 @@ +""" +Skeleton placeholders that stand in for dock widgets while a profile loads. + +A placeholder occupies the dock of a widget that is not built yet. It carries the widget class and +object name from the profile manifest, so a snapshot taken mid-load still describes the full +profile, and it shows a shimmering skeleton shaped like the widget it stands for. +""" + +from __future__ import annotations + +import math +import random + +from qtpy.QtCore import QPointF, QRectF, Qt, QTimer, Signal +from qtpy.QtGui import QColor, QFont, QLinearGradient, QPainter, QPainterPath, QPen +from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget + +from bec_widgets.widgets.containers.dock_area.profile_loading.common import ( + STATE_TEXT, + PlaceholderState, + loading_palette, + shimmer_phase, + skeleton_kind, + skeleton_seed, +) + + +class DockPlaceholder(QWidget): + """ + Base class for profile loading placeholders. + + Args: + widget_class(str): Class name of the widget this placeholder stands for. + object_name(str): Object name the real widget will get. + icon_name(str | None): Material icon of the widget class, shown on the dock tab. + parent(QWidget | None): Parent widget. + """ + + load_requested = Signal(str) + + def __init__( + self, + widget_class: str, + object_name: str, + icon_name: str | None = None, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.profile_widget_class = widget_class + self.profile_object_name = object_name + if icon_name: + # Read by the dock area to put the widget's icon on the dock tab + self.ICON_NAME = icon_name + self.setObjectName(object_name) + # The state manager must not save or restore anything for a placeholder + self.setProperty("skip_settings", True) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._state: PlaceholderState = "queued" + self._message = "" + + @property + def state(self) -> PlaceholderState: + """Current loading state.""" + return self._state + + @property + def message(self) -> str: + """Error message shown in the failed state.""" + return self._message + + def set_state(self, state: PlaceholderState, message: str = "") -> None: + """ + Update the loading state shown by the placeholder. + + Args: + state(PlaceholderState): New state. + message(str): Optional detail, used for the failed state. + """ + self._state = state + self._message = message + self._on_state_changed() + + def _on_state_changed(self) -> None: # pragma: no cover - implemented by subclasses + pass + + def minimumSizeHint(self): # noqa: N802 - Qt API + """Keep placeholders from forcing a large minimum dock size.""" + hint = super().minimumSizeHint() + hint.setWidth(min(max(hint.width(), 0), 120)) + hint.setHeight(min(max(hint.height(), 0), 80)) + return hint + + +class _ShimmerTicker: + """One timer animates every visible skeleton, so they shimmer in sync and cost one wake-up.""" + + _timer: QTimer | None = None + _targets: set[QWidget] = set() + + @classmethod + def add(cls, widget: QWidget) -> None: + """Animate *widget* until it is removed.""" + cls._targets.add(widget) + if cls._timer is None: + cls._timer = QTimer() + cls._timer.setInterval(33) + cls._timer.timeout.connect(cls._tick) + if not cls._timer.isActive(): + cls._timer.start() + + @classmethod + def remove(cls, widget: QWidget) -> None: + """Stop animating *widget*; the timer stops with the last one.""" + cls._targets.discard(widget) + if not cls._targets and cls._timer is not None: + cls._timer.stop() + cls._timer.deleteLater() + cls._timer = None + + @classmethod + def _tick(cls) -> None: + for widget in list(cls._targets): + widget.update() + + +class SkeletonPlaceholder(DockPlaceholder): + """QWidget placeholder that paints a shimmering skeleton of the widget it stands for.""" + + def __init__( + self, + widget_class: str, + object_name: str, + icon_name: str | None = None, + parent: QWidget | None = None, + ): + super().__init__(widget_class, object_name, icon_name, parent) + self._kind = skeleton_kind(widget_class) + self._seed = skeleton_seed(object_name) + + self._action = QPushButton("Load now", self) + self._action.setObjectName("placeholderAction") + self._action.setCursor(Qt.CursorShape.PointingHandCursor) + self._action.clicked.connect(lambda: self.load_requested.emit(self.profile_object_name)) + self._action.hide() + + # The button is the only child; the skeleton itself is painted + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 24) + layout.addStretch(1) + row = QHBoxLayout() + row.addStretch(1) + row.addWidget(self._action) + row.addStretch(1) + layout.addLayout(row) + + # Qt events ------------------------------------------------------------------------------ + + def showEvent(self, event): # noqa: N802 - Qt API + """Start the shimmer when the skeleton becomes visible.""" + super().showEvent(event) + if self._animated(): + _ShimmerTicker.add(self) + + def hideEvent(self, event): # noqa: N802 - Qt API + """Stop the shimmer while hidden.""" + _ShimmerTicker.remove(self) + super().hideEvent(event) + + def closeEvent(self, event): # noqa: N802 - Qt API + """Stop the shimmer before the placeholder is deleted.""" + _ShimmerTicker.remove(self) + super().closeEvent(event) + + def _animated(self) -> bool: + return self._state in ("queued", "loading") + + def _on_state_changed(self) -> None: + self._action.setVisible(self._state in ("paused", "failed")) + self._action.setText("Retry" if self._state == "failed" else "Load now") + if self._animated() and self.isVisible(): + _ShimmerTicker.add(self) + else: + _ShimmerTicker.remove(self) + self.update() + + # Painting ------------------------------------------------------------------------------- + + def paintEvent(self, _event): # noqa: N802 - Qt API + """Paint the card (or skeleton) with colors from the active theme.""" + colors = loading_palette() + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.fillRect(self.rect(), colors["window"]) + + area = QRectF(self.rect()).adjusted(14, 14, -14, -14) + caption_height = 30 if self._state in ("queued", "loading") else 78 + body = QRectF(area.left(), area.top(), area.width(), area.height() - caption_height - 10) + if body.height() > 24 and body.width() > 40: + path, accent_path = self._skeleton_paths(body) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(self._block_brush(colors, area)) + painter.drawPath(path) + if accent_path is not None: + accent = QColor(colors["accent"]) + accent.setAlphaF(0.28 if self._animated() else 0.14) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(accent, 2.0)) + painter.drawPath(accent_path) + + caption = QRectF(area.left(), area.bottom() - caption_height, area.width(), caption_height) + self._paint_caption(painter, caption, colors) + painter.end() + + def _block_brush(self, colors: dict[str, QColor], area: QRectF): + if not self._animated(): + return colors["block"] + # A soft band sweeps diagonally across all blocks + width = max(area.width(), 1.0) + phase = shimmer_phase() + x = area.left() - width * 0.6 + phase * width * 2.2 + gradient = QLinearGradient(QPointF(x - width * 0.35, 0), QPointF(x + width * 0.35, 0)) + gradient.setColorAt(0.0, colors["block"]) + gradient.setColorAt(0.5, colors["shine"]) + gradient.setColorAt(1.0, colors["block"]) + return gradient + + def _skeleton_paths(self, body: QRectF) -> tuple[QPainterPath, QPainterPath | None]: + """Blocks to shimmer, plus an optional accent trace, shaped like the widget kind.""" + rng = random.Random(self._seed) + if self._kind == "plot": + return self._plot_paths(body, rng) + if self._kind == "list": + return self._list_path(body, rng), None + return self._form_path(body, rng), None + + @staticmethod + def _plot_paths(body: QRectF, rng: random.Random) -> tuple[QPainterPath, QPainterPath | None]: + # Toolbar row, plot frame with axis ticks and a faint trace + path = QPainterPath() + for i in range(5): + path.addRoundedRect(QRectF(body.left() + i * 26, body.top(), 18, 18), 4, 4) + frame = QRectF(body.left() + 34, body.top() + 30, body.width() - 34, body.height() - 52) + outline = QPainterPath() + outline.addRoundedRect(frame, 6, 6) + inner = QPainterPath() + inner.addRoundedRect(frame.adjusted(2, 2, -2, -2), 5, 5) + path.addPath(outline.subtracted(inner)) + ticks_y = max(2, int(frame.height() // 48)) + for i in range(ticks_y + 1): + y = frame.top() + i * (frame.height() / ticks_y) - 4 + path.addRoundedRect(QRectF(body.left(), y, 24, 8), 3, 3) + ticks_x = max(2, int(frame.width() // 90)) + for i in range(ticks_x + 1): + x = frame.left() + i * (frame.width() / ticks_x) - 14 + path.addRoundedRect(QRectF(x, frame.bottom() + 10, 28, 8), 3, 3) + if frame.width() <= 60 or frame.height() <= 40: + return path, None + trace = QPainterPath() + freq = 1.5 + rng.random() * 2 + shift = rng.random() * math.pi + steps = 64 + for i in range(steps + 1): + t = i / steps + y = 0.5 + 0.32 * math.sin(t * freq * math.pi + shift) * math.exp(-1.2 * t) + point = QPointF( + frame.left() + 10 + t * (frame.width() - 20), + frame.top() + 10 + y * (frame.height() - 20), + ) + if i == 0: + trace.moveTo(point) + else: + trace.lineTo(point) + return path, trace + + @staticmethod + def _list_path(body: QRectF, rng: random.Random) -> QPainterPath: + # Header, then rows with a status dot and a label of varying length + path = QPainterPath() + path.addRoundedRect(QRectF(body.left(), body.top(), body.width() * 0.4, 14), 4, 4) + y = body.top() + 30 + while y + 16 < body.bottom(): + path.addEllipse(QRectF(body.left(), y, 14, 14)) + length = body.width() * (0.35 + 0.5 * rng.random()) + path.addRoundedRect(QRectF(body.left() + 24, y + 1, length - 24, 12), 4, 4) + path.addRoundedRect( + QRectF(body.right() - body.width() * 0.12, y + 1, body.width() * 0.12, 12), 4, 4 + ) + y += 30 + return path + + @staticmethod + def _form_path(body: QRectF, rng: random.Random) -> QPainterPath: + # Labels with input fields and a primary action + path = QPainterPath() + y = body.top() + field_w = body.width() + while y + 52 < body.bottom(): + path.addRoundedRect( + QRectF(body.left(), y, field_w * (0.18 + 0.15 * rng.random()), 10), 3, 3 + ) + path.addRoundedRect(QRectF(body.left(), y + 16, field_w, 26), 6, 6) + y += 58 + button_w = min(140, field_w * 0.4) + path.addRoundedRect(QRectF(body.right() - button_w, y + 4, button_w, 30), 6, 6) + return path + + def _paint_caption(self, painter: QPainter, rect: QRectF, colors: dict[str, QColor]) -> None: + font = QFont(self.font()) + font.setPointSizeF(max(8.0, font.pointSizeF() * 0.95)) + painter.setFont(font) + title = self.profile_widget_class + state = STATE_TEXT[self._state] + if self._state in ("queued", "loading"): + painter.setPen(colors["muted"]) + painter.drawText( + rect, + Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter, + f"{title} · {state}", + ) + return + color = colors["danger"] if self._state == "failed" else colors["muted"] + painter.setPen(color) + text_rect = QRectF(rect.left(), rect.top(), rect.width(), 22) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, f"{title} · {state}") + if self._message: + painter.setPen(colors["muted"]) + metrics = painter.fontMetrics() + detail = metrics.elidedText( + self._message, Qt.TextElideMode.ElideRight, int(rect.width()) + ) + painter.drawText( + QRectF(rect.left(), rect.top() + 20, rect.width(), 18), + Qt.AlignmentFlag.AlignCenter, + detail, + ) diff --git a/bec_widgets/widgets/containers/dock_area/profile_loading/progress.py b/bec_widgets/widgets/containers/dock_area/profile_loading/progress.py new file mode 100644 index 00000000..3ce36526 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_loading/progress.py @@ -0,0 +1,190 @@ +""" +Progress card shown over the dock area while a profile fills its docks. + +The card floats above the docks, so showing and hiding it never moves the layout. It appears only +when loading takes longer than a short delay, which keeps fast loads free of any flash. +""" + +from __future__ import annotations + +from qtpy.QtCore import QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer, Signal +from qtpy.QtGui import QColor, QFont, QPainter, QPainterPath, QPen +from qtpy.QtWidgets import ( + QGraphicsOpacityEffect, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.widgets.containers.dock_area.profile_loading.common import loading_palette + +#: Loads that finish faster than this never show the card. +SHOW_DELAY_MS = 180 +CARD_WIDTH = 380 +CARD_HEIGHT = 64 +CARD_MARGIN = 16 + + +class ProfileLoadProgressBase(QWidget): + """ + Interface of the loading progress card. + + Args: + parent(QWidget): Widget the card floats over (the dock manager). + """ + + cancel_requested = Signal() + + def __init__(self, parent: QWidget): + super().__init__(parent) + self.setObjectName("profileLoadProgress") + self.setProperty("skip_settings", True) + self.setFixedSize(CARD_WIDTH, CARD_HEIGHT) + self._profile = "" + self._done = 0 + self._total = 0 + self._current = "" + self._show_timer = QTimer(self) + self._show_timer.setSingleShot(True) + self._show_timer.setInterval(SHOW_DELAY_MS) + self._show_timer.timeout.connect(self._reveal) + self.hide() + + def begin(self, profile: str, total: int) -> None: + """Start reporting a load of *total* widgets for *profile*; the card shows after a delay.""" + self._profile = profile + self.update_progress(0, total, "") + self._show_timer.start() + + def update_progress(self, done: int, total: int, current: str) -> None: + """Report that *done* of *total* widgets are built and *current* is next.""" + self._done = done + self._total = total + self._current = current + self._render() + + def finish(self) -> None: + """Hide the card; called when the load completes or is cancelled.""" + self._show_timer.stop() + if self.isVisible(): + self._fade_out() + + def reposition(self) -> None: + """Keep the card in the bottom-right corner of its parent, clear of the dock title bars.""" + parent = self.parentWidget() + if parent is None: + return + self.move( + max(0, parent.width() - self.width() - CARD_MARGIN), + max(0, parent.height() - self.height() - CARD_MARGIN), + ) + + def _reveal(self) -> None: + self.reposition() + self.show() + self.raise_() + + def _fade_out(self) -> None: + self.hide() + + def _render(self) -> None: # pragma: no cover - implemented by subclasses + pass + + def subtitle(self) -> str: + """Second line of the card.""" + if self._total <= 0: + return "Preparing layout" + if self._done >= self._total: + return f"{self._total} of {self._total} ready" + current = f" · {self._current}" if self._current else "" + return f"{self._done} of {self._total} ready{current}" + + def fraction(self) -> float: + """Completed fraction in [0, 1].""" + return 0.0 if self._total <= 0 else min(1.0, self._done / self._total) + + +class ProfileLoadProgress(ProfileLoadProgressBase): + """QWidget implementation of the progress card.""" + + def __init__(self, parent: QWidget): + super().__init__(parent) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + self._title = QLabel(self) + title_font = QFont(self.font()) + title_font.setBold(True) + self._title.setFont(title_font) + self._subtitle = QLabel(self) + self._cancel = QPushButton("Cancel", self) + self._cancel.setObjectName("profileLoadCancel") + self._cancel.setFlat(True) + self._cancel.setCursor(Qt.CursorShape.PointingHandCursor) + self._cancel.clicked.connect(self.cancel_requested.emit) + + text = QVBoxLayout() + text.setSpacing(2) + text.addWidget(self._title) + text.addWidget(self._subtitle) + row = QHBoxLayout(self) + row.setContentsMargins(16, 8, 10, 12) + row.addLayout(text, 1) + row.addWidget(self._cancel, 0, Qt.AlignmentFlag.AlignVCenter) + + self._opacity = QGraphicsOpacityEffect(self) + self._opacity.setOpacity(1.0) + self.setGraphicsEffect(self._opacity) + self._fade = QPropertyAnimation(self._opacity, b"opacity", self) + self._fade.setDuration(220) + self._fade.setEasingCurve(QEasingCurve.Type.OutCubic) + self._fade.finished.connect(self._on_fade_finished) + + def _reveal(self) -> None: + self._fade.stop() + self._opacity.setOpacity(1.0) + super()._reveal() + + def _fade_out(self) -> None: + self._fade.stop() + self._fade.setStartValue(self._opacity.opacity()) + self._fade.setEndValue(0.0) + self._fade.start() + + def _on_fade_finished(self) -> None: + if self._fade.endValue() == 0.0: + self.hide() + self._opacity.setOpacity(1.0) + + def _render(self) -> None: + colors = loading_palette() + self._title.setText(f"Loading workspace “{self._profile}”") + self._subtitle.setText(self.subtitle()) + self._title.setStyleSheet(f"color: {colors['text'].name()}; background: transparent;") + self._subtitle.setStyleSheet(f"color: {colors['muted'].name()}; background: transparent;") + self._cancel.setStyleSheet( + f"QPushButton {{ color: {colors['accent'].name()}; border: none; padding: 6px 10px;" + f" background: transparent; font-weight: 600; }}" + f"QPushButton:hover {{ background: {colors['block'].name()}; border-radius: 6px; }}" + ) + self.update() + + def paintEvent(self, _event): # noqa: N802 - Qt API + """Paint the card (or skeleton) with colors from the active theme.""" + colors = loading_palette() + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(self.rect()).adjusted(1, 1, -1, -1) + card = QPainterPath() + card.addRoundedRect(rect, 10, 10) + painter.fillPath(card, colors["card"]) + painter.setPen(QPen(colors["border"], 1)) + painter.drawPath(card) + # Progress track along the bottom edge, clipped to the card's rounded corners + painter.setClipPath(card) + track = QRectF(rect.left(), rect.bottom() - 3, rect.width(), 3) + painter.fillRect(track, colors["block"]) + done = QRectF(track.left(), track.top(), track.width() * self.fraction(), track.height()) + painter.fillRect(done, QColor(colors["accent"])) + painter.end() diff --git a/bec_widgets/widgets/containers/dock_area/profile_loading/qml/DockSkeleton.qml b/bec_widgets/widgets/containers/dock_area/profile_loading/qml/DockSkeleton.qml new file mode 100644 index 00000000..13800322 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_loading/qml/DockSkeleton.qml @@ -0,0 +1,202 @@ +import QtQuick + +// Skeleton shown in a dock while its widget is being built. +Rectangle { + id: root + + property string widgetClass: "" + property string kind: "form" // plot | list | form + property string loadState: "queued" // queued | loading | paused | failed + property string message: "" + property int seed: 1 + property var colors: ({}) + readonly property bool animated: loadState === "queued" || loadState === "loading" + readonly property real captionHeight: animated ? 30 : 78 + + signal loadRequested() + + color: colors.window || "#1e1f22" + + // Shared shine position across the whole skeleton, in item coordinates + property real shineX: -width + NumberAnimation on shineX { + running: root.animated && root.visible + from: -root.width * 0.6 + to: root.width * 1.6 + duration: 1600 + loops: Animation.Infinite + } + + function rand(i) { + // Deterministic pseudo-random in [0, 1) so rows keep their shape + var x = Math.sin((root.seed % 9973) * 12.9898 + i * 78.233) * 43758.5453 + return x - Math.floor(x) + } + + component Block: Rectangle { + radius: 4 + readonly property real bandLeft: (root.shineX - x - body.x - root.width * 0.35) / Math.max(width, 1) + readonly property real bandRight: (root.shineX - x - body.x + root.width * 0.35) / Math.max(width, 1) + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: Math.min(Math.max(bandLeft, -1), 2); color: root.colors.block } + GradientStop { position: Math.min(Math.max((bandLeft + bandRight) / 2, -1), 2); color: root.animated ? root.colors.shine : root.colors.block } + GradientStop { position: Math.min(Math.max(bandRight, -1), 2); color: root.colors.block } + } + } + + Item { + id: body + x: 14; y: 14 + width: root.width - 28 + height: root.height - 28 - root.captionHeight - 10 + visible: height > 24 && width > 40 + + // Plot: toolbar, axis ticks, frame and a faint trace + Item { + anchors.fill: parent + visible: root.kind === "plot" + Repeater { + model: 5 + Block { x: index * 26; y: 0; width: 18; height: 18 } + } + Item { + id: frame + x: 34; y: 30 + width: body.width - 34 + height: body.height - 52 + Rectangle { + anchors.fill: parent + radius: 6 + color: "transparent" + border.width: 2 + border.color: root.colors.block + } + Canvas { + id: trace + anchors.fill: parent + anchors.margins: 10 + opacity: root.animated ? 0.28 : 0.14 + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + onPaint: { + var ctx = getContext("2d") + ctx.reset() + if (width < 40 || height < 20) + return + ctx.strokeStyle = root.colors.accent + ctx.lineWidth = 2 + ctx.beginPath() + var freq = 1.5 + root.rand(1) * 2 + var shift = root.rand(2) * Math.PI + for (var i = 0; i <= 64; i++) { + var t = i / 64 + var v = 0.5 + 0.32 * Math.sin(t * freq * Math.PI + shift) * Math.exp(-1.2 * t) + if (i === 0) ctx.moveTo(t * width, v * height) + else ctx.lineTo(t * width, v * height) + } + ctx.stroke() + } + } + } + Repeater { + model: Math.max(2, Math.floor(frame.height / 48)) + 1 + Block { + x: 0 + y: frame.y + index * (frame.height / Math.max(2, Math.floor(frame.height / 48))) - 4 + width: 24; height: 8; radius: 3 + } + } + Repeater { + model: Math.max(2, Math.floor(frame.width / 90)) + 1 + Block { + x: frame.x + index * (frame.width / Math.max(2, Math.floor(frame.width / 90))) - 14 + y: frame.y + frame.height + 10 + width: 28; height: 8; radius: 3 + } + } + } + + // List: header and rows + Item { + anchors.fill: parent + visible: root.kind === "list" + Block { x: 0; y: 0; width: body.width * 0.4; height: 14 } + Repeater { + model: Math.max(0, Math.floor((body.height - 46) / 30) + 1) + Item { + x: 0; y: 30 + index * 30 + width: body.width; height: 14 + Block { x: 0; y: 0; width: 14; height: 14; radius: 7 } + Block { x: 24; y: 1; width: body.width * (0.35 + 0.5 * root.rand(index + 3)) - 24; height: 12 } + Block { x: body.width * 0.88; y: 1; width: body.width * 0.12; height: 12 } + } + } + } + + // Form: labels, fields and a primary action + Item { + anchors.fill: parent + visible: root.kind === "form" + readonly property int rows: Math.max(0, Math.floor((body.height - 52) / 58) + 1) + Repeater { + model: parent.rows + Item { + x: 0; y: index * 58 + width: body.width; height: 42 + Block { x: 0; y: 0; width: body.width * (0.18 + 0.15 * root.rand(index + 7)); height: 10; radius: 3 } + Block { x: 0; y: 16; width: body.width; height: 26; radius: 6 } + } + } + Block { + width: Math.min(140, body.width * 0.4); height: 30; radius: 6 + x: body.width - width + y: parent.rows * 58 + 4 + visible: y + height <= body.height + 30 + } + } + } + + // Caption: what is loading, or why it is not + Column { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: root.animated ? 22 : 18 + spacing: 6 + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: root.widgetClass + " · " + ({queued: "Waiting", loading: "Loading", paused: "Not loaded", failed: "Could not load"})[root.loadState] + color: root.loadState === "failed" ? root.colors.danger : root.colors.muted + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(implicitWidth, root.width - 28) + visible: root.message.length > 0 && !root.animated + text: root.message + elide: Text.ElideRight + color: root.colors.muted + } + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + visible: !root.animated + width: actionText.implicitWidth + 28 + height: 30 + radius: 6 + color: actionArea.containsMouse ? Qt.darker(root.colors.accent, 1.1) : root.colors.accent + Text { + id: actionText + anchors.centerIn: parent + text: root.loadState === "failed" ? "Retry" : "Load now" + color: "white" + font.bold: true + } + MouseArea { + id: actionArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.loadRequested() + } + } + } +} diff --git a/bec_widgets/widgets/containers/dock_area/profile_loading/qml/LoadProgress.qml b/bec_widgets/widgets/containers/dock_area/profile_loading/qml/LoadProgress.qml new file mode 100644 index 00000000..90f6fe39 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_loading/qml/LoadProgress.qml @@ -0,0 +1,85 @@ +import QtQuick + +// Floating card that reports profile loading progress. +Item { + id: root + + property string profile: "" + property string subtitle: "" + property real fraction: 0 + property var colors: ({}) + + signal cancelRequested() + + Rectangle { + id: card + anchors.fill: parent + anchors.margins: 1 + radius: 10 + color: root.colors.card + border.color: root.colors.border + clip: true + + Column { + anchors.left: parent.left + anchors.leftMargin: 16 + anchors.right: cancel.left + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -2 + spacing: 2 + Text { + width: parent.width + text: "Loading workspace “" + root.profile + "”" + color: root.colors.text + font.bold: true + elide: Text.ElideRight + } + Text { + width: parent.width + text: root.subtitle + color: root.colors.muted + elide: Text.ElideRight + } + } + + Rectangle { + id: cancel + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -2 + width: cancelText.implicitWidth + 20 + height: 28 + radius: 6 + color: cancelArea.containsMouse ? root.colors.block : "transparent" + Text { + id: cancelText + anchors.centerIn: parent + text: "Cancel" + color: root.colors.accent + font.bold: true + } + MouseArea { + id: cancelArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.cancelRequested() + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 3 + color: root.colors.block + Rectangle { + height: parent.height + width: parent.width * root.fraction + color: root.colors.accent + Behavior on width { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } } + } + } + } +} diff --git a/bec_widgets/widgets/containers/dock_area/profile_loading/qml_variant.py b/bec_widgets/widgets/containers/dock_area/profile_loading/qml_variant.py new file mode 100644 index 00000000..1de76bc2 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_loading/qml_variant.py @@ -0,0 +1,116 @@ +""" +QML variant of the profile loading UI (``BEC_PROFILE_LOADING_UI=qml``). + +The placeholders and the progress card keep the Python interface of the QWidget variant and host +their QML scenes in a `QQuickWidget`. All placeholders share one QML engine, so only the first one +pays for engine start-up. +""" + +from __future__ import annotations + +from pathlib import Path + +from qtpy.QtCore import Qt, QUrl +from qtpy.QtGui import QColor +from qtpy.QtQml import QQmlEngine +from qtpy.QtQuickWidgets import QQuickWidget +from qtpy.QtWidgets import QVBoxLayout, QWidget + +from bec_widgets.widgets.containers.dock_area.profile_loading.common import ( + loading_palette, + skeleton_kind, + skeleton_seed, +) +from bec_widgets.widgets.containers.dock_area.profile_loading.placeholder import DockPlaceholder +from bec_widgets.widgets.containers.dock_area.profile_loading.progress import ( + ProfileLoadProgressBase, +) + +_QML_DIR = Path(__file__).resolve().parent / "qml" +_ENGINE: QQmlEngine | None = None + + +def _shared_engine() -> QQmlEngine: + global _ENGINE # pylint: disable=global-statement + if _ENGINE is None: + _ENGINE = QQmlEngine() + return _ENGINE + + +def _qml_palette() -> dict[str, str]: + return {key: color.name(QColor.NameFormat.HexArgb) for key, color in loading_palette().items()} + + +def _quick_widget(parent: QWidget, source: str, properties: dict) -> QQuickWidget: + view = QQuickWidget(_shared_engine(), parent) + view.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) + view.setInitialProperties(properties) + view.setSource(QUrl.fromLocalFile(str(_QML_DIR / source))) + return view + + +class QmlSkeletonPlaceholder(DockPlaceholder): + """Placeholder whose skeleton is a QML scene.""" + + def __init__( + self, + widget_class: str, + object_name: str, + icon_name: str | None = None, + parent: QWidget | None = None, + ): + super().__init__(widget_class, object_name, icon_name, parent) + self._view = _quick_widget( + self, + "DockSkeleton.qml", + { + "widgetClass": widget_class, + "kind": skeleton_kind(widget_class), + "seed": skeleton_seed(object_name) % 100000, + "colors": _qml_palette(), + }, + ) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._view) + root = self._view.rootObject() + if root is not None: + root.loadRequested.connect(lambda: self.load_requested.emit(self.profile_object_name)) + + def _on_state_changed(self) -> None: + root = self._view.rootObject() + if root is None: + return + root.setProperty("loadState", self.state) + root.setProperty("message", self.message) + + def closeEvent(self, event): # noqa: N802 - Qt API + """Unload the scene when the placeholder is replaced.""" + # Stop the shimmer animation right away; the widget is deleted later + self._view.setSource(QUrl()) + super().closeEvent(event) + + +class QmlProfileLoadProgress(ProfileLoadProgressBase): + """Progress card rendered in QML.""" + + def __init__(self, parent: QWidget): + super().__init__(parent) + self._view = _quick_widget(self, "LoadProgress.qml", {"colors": _qml_palette()}) + self._view.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop) + self._view.setClearColor(Qt.GlobalColor.transparent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._view) + root = self._view.rootObject() + if root is not None: + root.cancelRequested.connect(self.cancel_requested.emit) + + def _render(self) -> None: + root = self._view.rootObject() + if root is None: + return + root.setProperty("colors", _qml_palette()) + root.setProperty("profile", self._profile) + root.setProperty("subtitle", self.subtitle()) + root.setProperty("fraction", self.fraction()) diff --git a/bec_widgets/widgets/containers/dock_area/profile_utils.py b/bec_widgets/widgets/containers/dock_area/profile_utils.py index 53564b6f..2075b301 100644 --- a/bec_widgets/widgets/containers/dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/dock_area/profile_utils.py @@ -745,7 +745,10 @@ def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: settings.setArrayIndex(i) w = dock.widget() settings.setValue("object_name", w.objectName()) - settings.setValue("widget_class", w.__class__.__name__) + # A dock still loading holds a placeholder that knows the class it stands for + settings.setValue( + "widget_class", getattr(w, "profile_widget_class", None) or w.__class__.__name__ + ) settings.setValue("closable", getattr(dock, "_default_closable", True)) settings.setValue("floatable", getattr(dock, "_default_floatable", True)) settings.setValue("movable", getattr(dock, "_default_movable", True)) diff --git a/bec_widgets/widgets/containers/dock_area/settings/workspace_manager.py b/bec_widgets/widgets/containers/dock_area/settings/workspace_manager.py index a16cf5da..bf51c2fe 100644 --- a/bec_widgets/widgets/containers/dock_area/settings/workspace_manager.py +++ b/bec_widgets/widgets/containers/dock_area/settings/workspace_manager.py @@ -298,7 +298,8 @@ class WorkSpaceManager(BECWidget, QWidget): @SafeSlot(str) def switch_profile(self, profile_name: str): - self.target_widget.load_profile(profile_name) + load = getattr(self.target_widget, "load_profile_progressive", None) + (load or self.target_widget.load_profile)(profile_name) try: self.target_widget.toolbar.components.get_action( "workspace_combo" diff --git a/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py index b3d46df9..f0c6b92d 100644 --- a/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py @@ -190,6 +190,10 @@ class WorkspaceConnection(BundleConnection): if not hasattr(self.target_widget, "workspace_is_locked"): raise AttributeError("Target widget must implement 'workspace_is_locked'.") self._connected = False + # Switching from the toolbar keeps the GUI responsive while the profile fills + self._load_profile_slot = getattr( + self.target_widget, "load_profile_progressive", self.target_widget.load_profile + ) def connect(self): self._connected = True @@ -199,7 +203,7 @@ class WorkspaceConnection(BundleConnection): save_action.triggered.connect(self.target_widget.save_profile_dialog) self.components.get_action("workspace_combo").widget.currentTextChanged.connect( - self.target_widget.load_profile + self._load_profile_slot ) reset_action = self.components.get_action("reset_baseline_workspace").action @@ -218,7 +222,7 @@ class WorkspaceConnection(BundleConnection): if save_action.isVisible(): save_action.triggered.disconnect(self.target_widget.save_profile_dialog) self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect( - self.target_widget.load_profile + self._load_profile_slot ) reset_action = self.components.get_action("reset_baseline_workspace").action diff --git a/tests/unit_tests/benchmarks/test_dock_area_benchmark.py b/tests/unit_tests/benchmarks/test_dock_area_benchmark.py index 4e4da1ae..2280d2bc 100644 --- a/tests/unit_tests/benchmarks/test_dock_area_benchmark.py +++ b/tests/unit_tests/benchmarks/test_dock_area_benchmark.py @@ -25,3 +25,22 @@ def test_add_waveform_to_dock_area(benchmark, dock_area, qtbot, mocked_client): dock = benchmark(add_waveform) assert dock is not None + + +def test_switch_to_profile(benchmark, dock_area, qtbot): + """Benchmark switching to a saved profile with several plots (blocking load_profile).""" + for where in ("left", "right", "bottom"): + dock_area.new("Waveform", where=where) + dock_area.new("BECQueue", where="bottom") + dock_area.save_profile("bench_profile", show_dialog=False) + dock_area.delete_all() + dock_area.new("BECQueue") + dock_area.save_profile("bench_other", show_dialog=False) + + def switch(): + dock_area.load_profile("bench_other") + dock_area.load_profile("bench_profile") + + benchmark(switch) + + assert len(dock_area.widget_list()) == 4 diff --git a/tests/unit_tests/test_dock_area_profile_loading.py b/tests/unit_tests/test_dock_area_profile_loading.py new file mode 100644 index 00000000..097ee9e9 --- /dev/null +++ b/tests/unit_tests/test_dock_area_profile_loading.py @@ -0,0 +1,256 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, redefined-outer-name +from unittest import mock + +import pytest +from qtpy.QtWidgets import QApplication + +from bec_widgets.tests.utils import create_widget +from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea +from bec_widgets.widgets.containers.dock_area.profile_loading import loader as loader_module +from bec_widgets.widgets.containers.dock_area.profile_loading.common import ( + LOADING_MODE_ENV, + LOADING_UI_ENV, + skeleton_kind, +) +from bec_widgets.widgets.containers.dock_area.profile_loading.placeholder import ( + DockPlaceholder, + SkeletonPlaceholder, +) +from bec_widgets.widgets.containers.dock_area.profile_utils import ( + open_runtime_settings, + read_manifest, +) +from bec_widgets.widgets.plots.waveform.waveform import Waveform +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar + + +@pytest.fixture(autouse=True) +def isolate_profile_storage(tmp_path, monkeypatch): + root = tmp_path / "profiles_root" + root.mkdir() + monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(root)) + monkeypatch.delenv(LOADING_MODE_ENV, raising=False) + monkeypatch.delenv(LOADING_UI_ENV, raising=False) + + +@pytest.fixture +def dock_area(qtbot, mocked_client): + area = create_widget(qtbot, BECDockArea, client=mocked_client, startup_profile=None) + area.resize(1000, 700) + yield area + + +def _build_profile(area: BECDockArea, name: str) -> dict[str, str]: + """Save a profile with a titled waveform and two ring progress bars; return name -> class.""" + waveform = area.new("Waveform", object_name="plot") + waveform.title = "Saved title" + area.new("RingProgressBar", object_name="ring_a", where="bottom") + area.new("RingProgressBar", object_name="ring_b", relative_to="ring_a", where="right") + QApplication.processEvents() + area.save_profile(name, show_dialog=False) + return {"plot": "Waveform", "ring_a": "RingProgressBar", "ring_b": "RingProgressBar"} + + +def _switch_away(area: BECDockArea, qtbot) -> None: + area.delete_all() + area.new("RingProgressBar", object_name="other_ring") + area.save_profile("other", show_dialog=False) + area.load_profile("other") + qtbot.wait(10) + + +def _placeholders(area: BECDockArea) -> list[DockPlaceholder]: + return [d.widget() for d in area.dock_list() if isinstance(d.widget(), DockPlaceholder)] + + +def test_load_profile_builds_every_widget_before_returning(dock_area, qtbot): + expected = _build_profile(dock_area, "bench") + _switch_away(dock_area, qtbot) + + with qtbot.waitSignal(dock_area.profile_load_finished, timeout=1000) as blocker: + dock_area.load_profile("bench") + + assert blocker.args == ["bench"] + widgets = dock_area.widget_map() + assert {name: type(w).__name__ for name, w in widgets.items()} == expected + assert not _placeholders(dock_area) + assert widgets["plot"].title == "Saved title" + assert not dock_area.profile_load_in_progress + + +def test_progressive_load_shows_skeleton_then_fills(dock_area, qtbot): + expected = _build_profile(dock_area, "bench") + _switch_away(dock_area, qtbot) + + dock_area.load_profile_progressive("bench") + + # The layout exists right away, held by placeholders that know what they stand for + placeholders = _placeholders(dock_area) + assert {p.profile_object_name: p.profile_widget_class for p in placeholders} == expected + assert dock_area.profile_load_in_progress + assert dock_area._current_profile_name == "bench" + + qtbot.waitUntil(lambda: not dock_area.profile_load_in_progress, timeout=5000) + assert not _placeholders(dock_area) + widgets = dock_area.widget_map() + assert isinstance(widgets["plot"], Waveform) + assert widgets["plot"].title == "Saved title" + assert isinstance(widgets["ring_b"], RingProgressBar) + + +def test_progressive_load_reports_progress(dock_area, qtbot): + _build_profile(dock_area, "bench") + _switch_away(dock_area, qtbot) + reports = [] + + dock_area.load_profile_progressive("bench") + dock_area._profile_load_job.progress.connect(lambda *args: reports.append(args)) + qtbot.waitUntil(lambda: not dock_area.profile_load_in_progress, timeout=5000) + + assert [done for done, _total, _next in reports] == [1, 2, 3, 3] + assert all(total == 3 for _done, total, _next in reports) + assert not dock_area._profile_load_progress._show_timer.isActive() + + +def test_cancel_leaves_load_now_placeholders(dock_area, qtbot): + _build_profile(dock_area, "bench") + _switch_away(dock_area, qtbot) + + with mock.patch.object(loader_module.ProfileLoadJob, "_step"): + dock_area.load_profile_progressive("bench") + dock_area.cancel_profile_load() + + placeholders = _placeholders(dock_area) + assert len(placeholders) == 3 + assert {p.state for p in placeholders} == {"paused"} + assert not dock_area.profile_load_in_progress + + placeholders[0].load_requested.emit(placeholders[0].profile_object_name) + assert len(_placeholders(dock_area)) == 2 + + +def test_snapshot_mid_load_keeps_unbuilt_widgets(dock_area, qtbot): + _build_profile(dock_area, "bench") + _switch_away(dock_area, qtbot) + + with mock.patch.object(loader_module.ProfileLoadJob, "_step"): + dock_area.load_profile_progressive("bench") + dock_area.cancel_profile_load() + dock_area.save_profile("bench_copy", show_dialog=False) + + settings = open_runtime_settings("bench_copy", namespace=dock_area.profile_namespace) + classes = {item["object_name"]: item["widget_class"] for item in read_manifest(settings)} + assert classes == {"plot": "Waveform", "ring_a": "RingProgressBar", "ring_b": "RingProgressBar"} + # The unbuilt waveform's saved state was carried over into the new profile + assert any(key.endswith("plot/title") for key in settings.allKeys()) + + +def test_switching_mid_load_discards_the_running_load(dock_area, qtbot): + _build_profile(dock_area, "bench") + _switch_away(dock_area, qtbot) + + with mock.patch.object(loader_module.ProfileLoadJob, "_step"): + dock_area.load_profile_progressive("bench") + first_job = dock_area._profile_load_job + dock_area.load_profile("other") + + assert dock_area._profile_load_job is not first_job + assert set(dock_area.widget_map()) == {"other_ring"} + # Leaving "bench" mid-load saved it with all of its docks + settings = open_runtime_settings("bench", namespace=dock_area.profile_namespace) + assert {item["object_name"] for item in read_manifest(settings)} == {"plot", "ring_a", "ring_b"} + + +def test_failing_widget_does_not_abort_the_profile(dock_area, qtbot, monkeypatch): + _build_profile(dock_area, "bench") + _switch_away(dock_area, qtbot) + original = loader_module.widget_handler.create_widget + + def create(widget_type, **kwargs): + if widget_type == "Waveform": + raise RuntimeError("device missing") + return original(widget_type=widget_type, **kwargs) + + monkeypatch.setattr(loader_module.widget_handler, "create_widget", create) + dock_area.load_profile("bench") + + failed = _placeholders(dock_area) + assert [(p.profile_object_name, p.state, p.message) for p in failed] == [ + ("plot", "failed", "device missing") + ] + assert set(dock_area.widget_map()) == {"ring_a", "ring_b"} + + +def test_toolbar_combo_switches_progressively(dock_area, qtbot): + _build_profile(dock_area, "bench") + _switch_away(dock_area, qtbot) + combo = dock_area.toolbar.components.get_action("workspace_combo").widget + + with mock.patch.object(dock_area, "load_profile_progressive") as load: + combo.currentTextChanged.emit("bench") + + load.assert_called_once_with("bench") + + +def test_legacy_pipeline_still_available(dock_area, qtbot, monkeypatch): + expected = _build_profile(dock_area, "bench") + _switch_away(dock_area, qtbot) + monkeypatch.setenv(LOADING_MODE_ENV, "legacy") + + with mock.patch.object(loader_module.ProfileLoadJob, "build_skeleton") as skeleton: + dock_area.load_profile_progressive("bench") + + skeleton.assert_not_called() + assert {n: type(w).__name__ for n, w in dock_area.widget_map().items()} == expected + + +def test_qml_variant(dock_area, qtbot, monkeypatch): + from bec_widgets.widgets.containers.dock_area.profile_loading.qml_variant import ( + QmlProfileLoadProgress, + QmlSkeletonPlaceholder, + ) + + _build_profile(dock_area, "bench") + _switch_away(dock_area, qtbot) + monkeypatch.setenv(LOADING_UI_ENV, "qml") + dock_area._profile_load_progress = None + + with mock.patch.object(loader_module.ProfileLoadJob, "_step"): + dock_area.load_profile_progressive("bench") + placeholders = _placeholders(dock_area) + assert placeholders and all(isinstance(p, QmlSkeletonPlaceholder) for p in placeholders) + assert isinstance(dock_area._profile_load_progress, QmlProfileLoadProgress) + + placeholders[0].set_state("failed", "boom") + root = placeholders[0]._view.rootObject() + assert root.property("loadState") == "failed" + assert root.property("message") == "boom" + + dock_area._profile_load_job.run_to_completion() + assert not _placeholders(dock_area) + + +def test_placeholder_states_and_kinds(qtbot): + placeholder = SkeletonPlaceholder("BECQueue", "queue", "edit_note") + qtbot.addWidget(placeholder) + assert placeholder.property("skip_settings") is True + assert placeholder.ICON_NAME == "edit_note" + assert placeholder.state == "queued" + placeholder.set_state("paused") + assert placeholder._action.isVisibleTo(placeholder) + with qtbot.waitSignal(placeholder.load_requested) as blocker: + placeholder._action.click() + assert blocker.args == ["queue"] + assert skeleton_kind("Waveform") == "plot" + assert skeleton_kind("BECQueue") == "list" + assert skeleton_kind("ScanControl") == "form" + + +def test_delete_all_closes_every_widget(dock_area, qtbot): + _build_profile(dock_area, "bench") + widgets = list(dock_area.widget_list()) + + dock_area.delete_all() + + assert dock_area.dock_list() == [] + assert all(w._destroyed for w in widgets)