perf(dock_area): load profiles as skeleton docks that fill progressively without freezing the GUI

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MogxEn2tLjm1NHpex9N6Uu
This commit is contained in:
Claude committed 2026-10-09 23:21:42 +00:00
1 parent 703513b175
commit 8354ccba8a
16 files changed
+1861 -26

No files matched your search

+13
View File
@@ -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
):
@@ -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):
@@ -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
@@ -0,0 +1 @@
"""Progressive profile loading: skeleton docks that fill with their widgets one at a time."""
@@ -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,
}
@@ -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)
@@ -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,
)
@@ -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()
@@ -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()
}
}
}
}
@@ -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 } }
}
}
}
}
@@ -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())
@@ -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))
@@ -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"
@@ -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
@@ -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
@@ -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)