mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-30 16:21:28 +01:00
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:
16 files changed
+1861
-26
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user