merge: build on the profile management rework (claude/profile-management-ux-jlv1vs)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpeYDYWxwjcsqJt2BwhDNR
This commit is contained in:
Claude committed 2026-10-09 22:42:00 +00:00
commit 8d6d276928
20 files changed
+4895 -23

No files matched your search

@@ -1,15 +1,20 @@
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.view import ViewBase
from bec_widgets.applications.views.view import ViewBase, ViewTourSteps
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.dock_area.workspace_tabs import WorkspaceTabs
class DockAreaView(ViewBase):
"""
Modular dock area view for arranging and managing multiple dockable widgets.
Each workspace lives in its own tab with its own dock area and layout. RPC calls are
forwarded to the dock area of the current tab.
"""
RPC_CONTENT_CLASS = BECDockArea
RPC_CONTENT_ATTR = "dock_area"
def __init__(
self,
@@ -21,11 +26,36 @@ class DockAreaView(ViewBase):
**kwargs,
):
super().__init__(parent=parent, content=content, view_id=view_id, title=title, **kwargs)
self.dock_area = BECDockArea(
self,
profile_namespace="bec",
auto_profile_namespace=False,
object_name="DockArea",
rpc_exposed=False,
self.workspaces = WorkspaceTabs(self, profile_namespace="bec", rpc_exposed=False)
self.set_content(self.workspaces)
@property
def dock_area(self) -> BECDockArea | None:
"""The dock area of the current workspace tab."""
return self.workspaces.current_dock_area()
def register_tour_steps(self, guided_tour, main_app):
"""Register the current dock area's components with the guided tour."""
tabs = self.workspaces
def get_workspace_tabs():
main_app.set_current("dock_area")
return (tabs.tabBar(), None)
step_id = guided_tour.register_widget(
widget=get_workspace_tabs,
title="Workspace Tabs",
text="Each tab is its own workspace with its own layout. Use + to open a new or "
"saved workspace, double-click a tab to rename it, and drag tabs to reorder them. "
"Open tabs are restored on the next start.",
)
self.set_content(self.dock_area)
dock_area = self.dock_area
view_tour = dock_area.register_tour_steps(guided_tour, main_app) if dock_area else None
if view_tour is None:
return ViewTourSteps(view_title="Dock Area Workspace", step_ids=[step_id])
view_tour.step_ids.insert(0, step_id)
return view_tour
def cleanup(self):
self.workspaces.cleanup()
super().cleanup()
@@ -10,6 +10,8 @@ Rectangle {
property alias headerExtras: extras.data
property int padding: 12
property alias spacing: column.spacing
// Let the content column take the remaining height, e.g. for a list that fills the card.
property bool fillContent: false
color: theme.card
radius: 10
@@ -47,6 +49,7 @@ Rectangle {
ColumnLayout {
id: column
Layout.fillWidth: true
Layout.fillHeight: root.fillContent
spacing: 8
}
}
@@ -5,6 +5,7 @@ import QtQuick.Controls.Basic
ComboBox {
id: root
property bool invalid: false
property string placeholder: ""
implicitHeight: 32
implicitWidth: 140
@@ -22,6 +23,8 @@ ComboBox {
rightPadding: 4
text: root.editable ? root.editText : root.displayText
readOnly: !root.editable
placeholderText: root.placeholder
placeholderTextColor: theme.fgSubtle
enabled: root.enabled
color: theme.fg
selectionColor: theme.primary
+94
View File
@@ -14,6 +14,7 @@ from qtpy.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QToolButton,
@@ -324,3 +325,96 @@ class PortedPropertiesMixin:
if cls is self.PORTED_FROM:
break
return objects
class ToggleSwitch(QPushButton):
"""Checkable switch painted like ``BecUi.SwitchField``; the label shows On or Off."""
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setCheckable(True)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setMinimumHeight(32)
self.setFixedWidth(84)
self._knob = 0.0
self._animation = QPropertyAnimation(self, b"knob", self)
self._animation.setDuration(120)
self._animation.setEasingCurve(QEasingCurve.Type.OutCubic)
self.toggled.connect(self._animate)
def _animate(self, checked: bool) -> None:
self._animation.stop()
self._animation.setStartValue(self._knob)
self._animation.setEndValue(1.0 if checked else 0.0)
self._animation.start()
def setChecked(self, checked: bool) -> None: # pylint: disable=invalid-name
"""Set the state without animating."""
self.blockSignals(True)
super().setChecked(checked)
self.blockSignals(False)
self._animation.stop()
self._knob = 1.0 if checked else 0.0
self.update()
def _get_knob(self) -> float:
return self._knob
def _set_knob(self, value: float) -> None:
self._knob = value
self.update()
knob = Property(float, _get_knob, _set_knob)
def paintEvent(self, _event): # pylint: disable=invalid-name
tokens = ThemeTokens()
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
track = QRectF(0, (self.height() - 22) / 2, 38, 22)
painter.setPen(tokens.primary if self.isChecked() else tokens.border)
painter.setBrush(tokens.primary if self.isChecked() else tokens.track)
painter.drawRoundedRect(track, 11, 11)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(tokens.on_primary if self.isChecked() else tokens.fg_muted)
painter.drawEllipse(QRectF(track.left() + 3 + self._knob * 16, track.top() + 3, 16, 16))
painter.setPen(tokens.fg)
font = self.font()
font.setPixelSize(13)
painter.setFont(font)
painter.drawText(
QRectF(track.right() + 8, 0, self.width() - track.right() - 8, self.height()),
Qt.AlignmentFlag.AlignVCenter,
"On" if self.isChecked() else "Off",
)
painter.end()
class SuffixLineEdit(QLineEdit):
"""Line edit that paints a unit suffix inside its right edge, like ``BecUi.InputField``."""
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._suffix = ""
def set_suffix(self, suffix: str) -> None:
"""Set the unit shown at the right edge."""
self._suffix = suffix or ""
width = self.fontMetrics().horizontalAdvance(self._suffix) + 10 if self._suffix else 0
self.setTextMargins(0, 0, width, 0)
self.update()
def paintEvent(self, event): # pylint: disable=invalid-name
super().paintEvent(event)
if not self._suffix:
return
painter = QPainter(self)
painter.setPen(ThemeTokens().fg_subtle)
font = self.font()
font.setPixelSize(12)
painter.setFont(font)
painter.drawText(
self.rect().adjusted(0, 0, -9, 0),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
self._suffix,
)
painter.end()
@@ -2,7 +2,7 @@ from __future__ import annotations
import os
from functools import lru_cache
from typing import Literal, Mapping, Sequence
from typing import Callable, Literal, Mapping, Sequence
import slugify
from bec_lib import bec_logger
@@ -69,6 +69,12 @@ from bec_widgets.widgets.containers.dock_area.profile_utils import (
set_quick_select,
write_manifest,
)
from bec_widgets.widgets.containers.dock_area.profile_ux import (
ask_profile_name,
confirm_revert,
create_profile_library,
profile_ui_mode,
)
from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
RestoreProfileDialog,
SaveProfileDialog,
@@ -130,6 +136,8 @@ class BECDockArea(DockAreaWidget):
# Define a signal for mode changes
mode_changed = Signal(str)
profile_changed = Signal(str)
# Emitted instead of loading when the profile is already open in another dock area
profile_redirected = Signal(str)
def __init__(
self,
@@ -151,6 +159,10 @@ 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)
# Optional hook set by a container (e.g. workspace tabs) that hosts several dock areas
self.profile_in_use_elsewhere: Callable[[str], bool] | None = None
# The WorkspaceTabs hosting this dock area, if any; used by the profile library
self.workspace_host = None
super().__init__(
parent, default_add_direction=default_add_direction, title="BEC Dock Area", **kwargs
)
@@ -179,6 +191,7 @@ class BECDockArea(DockAreaWidget):
# Popups
self.save_dialog = None
self.manage_dialog = None
self.manage_widget = None
# Place toolbar above the dock manager provided by the base class
self._root_layout.insertWidget(0, self.toolbar)
@@ -509,7 +522,11 @@ class BECDockArea(DockAreaWidget):
self.toolbar.add_bundle(spacer_bundle)
self.toolbar.add_bundle(
workspace_bundle(self.toolbar.components, enable_tools=self._profile_management_enabled)
workspace_bundle(
self.toolbar.components,
enable_tools=self._profile_management_enabled,
reworked=profile_ui_mode() != "legacy",
)
)
self.toolbar.connect_bundle(
"workspace", WorkspaceConnection(components=self.toolbar.components, target_widget=self)
@@ -809,6 +826,10 @@ class BECDockArea(DockAreaWidget):
"""Namespace used to scope runtime/baseline profile files for this dock area."""
return self._resolve_profile_namespace()
def _is_profile_in_use_elsewhere(self, name: str | None) -> bool:
check = self.profile_in_use_elsewhere
return bool(name) and check is not None and bool(check(name))
def _profile_exists(self, name: str, namespace: str | None) -> bool:
return any(
os.path.exists(path) for path in runtime_profile_candidates(name, namespace)
@@ -945,6 +966,10 @@ class BECDockArea(DockAreaWidget):
Only used when show_dialog is False; otherwise the dialog provides the value.
"""
if show_dialog and profile_ui_mode() != "legacy":
ask_profile_name(self, "save")
return
namespace = self.profile_namespace
current_profile = getattr(self, "_current_profile_name", "") or ""
@@ -995,13 +1020,21 @@ class BECDockArea(DockAreaWidget):
# Overwrite existing settings profile when saving programmatically
overwrite_existing = origin == "settings"
if name != current_profile and self._is_profile_in_use_elsewhere(name):
message = f"Profile '{name}' is open in another workspace tab."
if show_dialog:
QMessageBox.information(self, "Save Profile", message)
else:
logger.warning(message)
return
origin_before_save = profile_origin(name, namespace=namespace)
overwrite_baseline = overwrite_existing and origin_before_save == "settings"
# Display saving placeholder in toolbar
# Show the new name in the toolbar right away; the stored preview screenshot shows it
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
workspace_combo.blockSignals(True)
workspace_combo.insertItem(0, f"{name}-saving")
workspace_combo.insertItem(0, name)
workspace_combo.setCurrentIndex(0)
workspace_combo.blockSignals(False)
@@ -1065,8 +1098,13 @@ class BECDockArea(DockAreaWidget):
if not ok or not name:
return
namespace = self.profile_namespace
prev_name = getattr(self, "_current_profile_name", None)
if name != prev_name and self._is_profile_in_use_elsewhere(name):
self._refresh_workspace_list()
self.profile_redirected.emit(name)
return
namespace = self.profile_namespace
skip_pair = getattr(self, "_pending_autosave_skip", None)
if prev_name and prev_name != name:
if skip_pair and skip_pair == (prev_name, name):
@@ -1145,7 +1183,10 @@ class BECDockArea(DockAreaWidget):
return
namespace = self.profile_namespace
if show_dialog:
if show_dialog and profile_ui_mode() != "legacy":
if not confirm_revert(self, target):
return
elif show_dialog:
current_pixmap = None
if self.isVisible():
current_pixmap = QPixmap()
@@ -1291,6 +1332,16 @@ class BECDockArea(DockAreaWidget):
Show the workspace manager dialog.
"""
manage_action = self.toolbar.components.get_action("manage_workspaces").action
if profile_ui_mode() != "legacy":
if self.manage_dialog is None:
self.manage_widget = None
self.manage_dialog = create_profile_library(self)
self.manage_dialog.finished.connect(self._manage_dialog_closed)
self.manage_dialog.show()
self.manage_dialog.raise_()
self.manage_dialog.activateWindow()
manage_action.setChecked(True)
return
if self.manage_dialog is None or not self.manage_dialog.isVisible():
self.manage_widget = WorkSpaceManager(
self, target_widget=self, active_profile=self._current_profile_name
@@ -1312,8 +1363,10 @@ class BECDockArea(DockAreaWidget):
manage_action.setChecked(True) # keep it toggle
def _manage_dialog_closed(self):
self.manage_widget.close()
self.manage_widget.deleteLater()
if self.manage_widget is not None:
self.manage_widget.close()
self.manage_widget.deleteLater()
self.manage_widget = None
if self.manage_dialog is not None:
self.manage_dialog.deleteLater()
self.manage_dialog = None
@@ -469,6 +469,8 @@ SETTINGS_KEYS = {
"screenshot": "profile/screenshot",
"screenshot_at": "profile/screenshot_at",
"last_profile": "app/last_profile",
"open_workspaces": "app/open_workspaces",
"current_workspace": "app/current_workspace",
}
@@ -664,6 +666,60 @@ def set_last_profile(
s.remove(key)
def _workspace_tabs_key(key: str, namespace: str | None) -> str:
ns = slugify.slugify(namespace, separator="_") if namespace else None
base = SETTINGS_KEYS[key]
return f"{base}/{ns}" if ns else base
def get_open_workspaces(namespace: str | None = None) -> tuple[list[str], str | None]:
"""
Retrieve the workspace tabs that were open when the app last persisted them.
Args:
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
tuple[list[str], str | None]: Profile names in tab order and the profile
of the active tab (``None`` when unknown).
"""
s = _app_settings()
raw = s.value(_workspace_tabs_key("open_workspaces", namespace), [])
if isinstance(raw, str):
raw = [raw] if raw else []
names: list[str] = []
for name in raw or []:
name = str(name).strip()
if name and name not in names:
names.append(name)
current = s.value(_workspace_tabs_key("current_workspace", namespace), "", type=str)
return names, (current or None)
def set_open_workspaces(
names: list[str], current: str | None = None, namespace: str | None = None
) -> None:
"""
Persist the open workspace tabs (profile names in tab order) and the active one.
Args:
names (list[str]): Profile names in tab order. Unsaved workspaces are left out.
current (str | None, optional): Profile of the active tab. Defaults to ``None``.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
"""
s = _app_settings()
key = _workspace_tabs_key("open_workspaces", namespace)
if names:
s.setValue(key, list(names))
else:
s.remove(key)
current_key = _workspace_tabs_key("current_workspace", namespace)
if current:
s.setValue(current_key, current)
else:
s.remove(current_key)
def is_experimental_features_enabled() -> bool:
"""
Check whether experimental features are enabled globally.
@@ -1118,3 +1174,199 @@ def load_runtime_profile_screenshot(name: str, namespace: str | None = None) ->
if s is None:
return None
return _load_screenshot_from_settings(s)
##################################################
# Profile library helpers
##################################################
TRASH_DIR_NAME = "_trash"
def set_profile_notes(name: str, notes: str, namespace: str | None = None) -> None:
"""
Store a short free-text description of a profile in its runtime and saved copies.
Args:
name (str): Profile name without extension.
notes (str): The description; an empty string clears it.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
"""
notes = (notes or "").strip()
targets = [open_runtime_settings(name, namespace)]
if not is_profile_read_only(name, namespace) and _existing_baseline_settings(name, namespace):
targets.append(open_baseline_settings(name, namespace))
for settings in targets:
if notes:
settings.setValue("profile/notes", notes)
else:
settings.remove("profile/notes")
settings.sync()
def has_saved_baseline(name: str, namespace: str | None = None) -> bool:
"""
Check whether a profile has a saved copy that its working copy can be reverted to.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
bool: ``True`` when a baseline copy exists.
"""
return any(os.path.exists(path) for path in baseline_profile_candidates(name, namespace))
def copy_profile(source: str, target: str, namespace: str | None = None) -> None:
"""
Copy a stored profile under a new name, as working copy and saved copy.
The working (runtime) copy of *source* is preferred so the copy shows what the user last
saw. The copy is a regular user profile, even when *source* is a bundled one.
Args:
source (str): Name of the profile to copy.
target (str): Name of the new profile. It must not exist yet.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Raises:
FileNotFoundError: If *source* has no stored files.
FileExistsError: If *target* already exists.
"""
if profile_origin(target, namespace) != "unknown":
raise FileExistsError(f"Profile '{target}' already exists.")
src = next(
(p for p in runtime_profile_candidates(source, namespace) if os.path.exists(p)), None
)
if src is None:
src = next(
(p for p in baseline_profile_candidates(source, namespace) if os.path.exists(p)), None
)
if src is None:
raise FileNotFoundError(f"Profile '{source}' has no stored files.")
created = now_iso_utc()
for dst in (runtime_profile_path(target, namespace), baseline_profile_path(target, namespace)):
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copyfile(src, dst)
settings = QSettings(dst, QSettings.IniFormat)
settings.setValue(SETTINGS_KEYS["created_at"], created)
settings.remove("profile/author")
settings.sync()
class TrashedProfile(BaseModel):
"""A profile moved to the profile trash; :func:`restore_trashed_profile` puts it back."""
token: str
name: str
namespace: str | None = None
deleted_at: str = ""
files: list[tuple[str, str]] = Field(default_factory=list)
def _trash_root(namespace: str | None) -> Path:
ns = slugify.slugify(namespace, separator="_") if namespace else "_global"
return Path(_settings_profiles_root()) / TRASH_DIR_NAME / ns
def trash_profile(name: str, namespace: str | None = None) -> TrashedProfile | None:
"""
Move every writable file of a profile into the profile trash instead of deleting it.
Bundled read-only profiles cannot be trashed. The files are moved, not removed, so the
profile can be restored at any time with :func:`restore_trashed_profile`.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
TrashedProfile | None: Description of the trashed profile, or ``None`` when no
writable files were found or the profile is read-only.
"""
if is_profile_read_only(name, namespace):
return None
sources = [
path
for path in dict.fromkeys(
runtime_profile_candidates(name, namespace)
+ baseline_profile_candidates(name, namespace)
)
if os.path.exists(path)
]
if not sources:
return None
deleted_at = now_iso_utc()
stamp = deleted_at.replace(":", "").replace("-", "")
token = f"{slugify.slugify(name, separator='_') or 'profile'}__{stamp}"
folder = _trash_root(namespace) / token
suffix = 1
while folder.exists():
suffix += 1
folder = _trash_root(namespace) / f"{token}_{suffix}"
folder.mkdir(parents=True)
files: list[tuple[str, str]] = []
for index, src in enumerate(sources):
dst = folder / f"{index}_{Path(src).name}"
shutil.move(src, dst)
files.append((str(dst), src))
entry = TrashedProfile(
token=folder.name, name=name, namespace=namespace, deleted_at=deleted_at, files=files
)
(folder / "trash.json").write_text(entry.model_dump_json(indent=2), encoding="utf-8")
if get_last_profile(namespace) == name:
set_last_profile(None, namespace)
return entry
def list_trashed_profiles(namespace: str | None = None) -> list[TrashedProfile]:
"""
List the profiles in the profile trash, most recently deleted first.
Args:
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
list[TrashedProfile]: Trashed profiles.
"""
root = _trash_root(namespace)
if not root.is_dir():
return []
entries = []
for meta in root.glob("*/trash.json"):
try:
entries.append(TrashedProfile.model_validate_json(meta.read_text(encoding="utf-8")))
except Exception as exc: # pylint: disable=broad-except
logger.warning(f"Skipping unreadable trash entry {meta}: {exc}")
return sorted(entries, key=lambda e: e.deleted_at, reverse=True)
def restore_trashed_profile(token: str, namespace: str | None = None) -> str:
"""
Put a trashed profile back where it was.
Args:
token (str): The ``token`` of the :class:`TrashedProfile`.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
str: The name of the restored profile.
Raises:
FileNotFoundError: If the trash entry does not exist.
FileExistsError: If a profile with the same name was created in the meantime.
"""
folder = _trash_root(namespace) / token
meta = folder / "trash.json"
if not meta.exists():
raise FileNotFoundError(f"No trashed profile '{token}'.")
entry = TrashedProfile.model_validate_json(meta.read_text(encoding="utf-8"))
if profile_origin(entry.name, namespace) != "unknown":
raise FileExistsError(f"A profile named '{entry.name}' exists again.")
for trashed, original in entry.files:
os.makedirs(os.path.dirname(original), exist_ok=True)
shutil.move(trashed, original)
# The folder only holds the metadata now; keep it as a record of the restore.
meta.rename(folder / "restored.json")
return entry.name
@@ -0,0 +1,96 @@
"""Reworked profile management windows for the dock area, in a QWidget and a QML version.
``BEC_PROFILE_UI=qwidget`` or ``BEC_PROFILE_UI=qml`` opts into them; the default keeps the
previous dialogs. Both versions share the rules and actions in :mod:`.common`.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from bec_widgets.widgets.containers.dock_area.profile_ux.common import (
POLICY,
UI_ENV,
NameMode,
ProfileActions,
ProfilePolicy,
UiMode,
profile_ui_mode,
)
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QDialog, QWidget
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
__all__ = [
"POLICY",
"UI_ENV",
"ProfileActions",
"ProfilePolicy",
"ask_profile_name",
"confirm_revert",
"create_profile_library",
"profile_ui_mode",
]
def _views(ui: UiMode | None):
ui = ui or profile_ui_mode()
if ui == "qml":
from bec_widgets.widgets.containers.dock_area.profile_ux import profile_qml as views
else:
from bec_widgets.widgets.containers.dock_area.profile_ux import profile_qwidget as views
return views
def create_profile_library(
dock_area: BECDockArea, parent: QWidget | None = None, ui: UiMode | None = None
) -> QDialog:
"""
Create the non-modal profile library window for *dock_area*.
Args:
dock_area(BECDockArea): The dock area the library acts on.
parent(QWidget | None): Parent of the window; defaults to the dock area.
ui(UiMode | None): ``qwidget`` or ``qml``; defaults to ``BEC_PROFILE_UI``.
"""
views = _views(ui)
return views.ProfileLibraryDialog(ProfileActions(dock_area), parent or dock_area)
def ask_profile_name(
dock_area: BECDockArea,
mode: NameMode,
original: str = "",
parent: QWidget | None = None,
ui: UiMode | None = None,
) -> str | None:
"""
Ask for a name and save, rename or duplicate a profile of *dock_area*.
Args:
dock_area(BECDockArea): The dock area the request comes from.
mode(NameMode): ``save``, ``rename`` or ``duplicate``.
original(str): The profile to rename or duplicate.
parent(QWidget | None): Dialog parent; defaults to the dock area.
ui(UiMode | None): ``qwidget`` or ``qml``; defaults to ``BEC_PROFILE_UI``.
Returns:
str | None: The resulting profile name, or None when cancelled.
"""
views = _views(ui)
return views.run_name_flow(ProfileActions(dock_area), mode, original, parent or dock_area)
def confirm_revert(
dock_area: BECDockArea, name: str, parent: QWidget | None = None, ui: UiMode | None = None
) -> bool:
"""
Ask whether to revert *name* to its saved layout.
Returns:
bool: True when the user confirmed; the caller performs the revert.
"""
views = _views(ui)
return views.run_revert_flow(ProfileActions(dock_area), name, parent or dock_area)
@@ -0,0 +1,942 @@
"""State, rules and actions shared by the QWidget and QML profile management windows.
The windows themselves only render what :class:`ProfileLibraryController` and
:func:`check_profile_name` hand them, so both versions behave the same. Everything that
touches profile files goes through :class:`ProfileActions`.
Two behaviours are still open decisions and live in :data:`POLICY` so either answer works:
- ``rename_keeps_old_profile``: renaming saves the layout under the new name and keeps the
old profile in the library (current behaviour), or moves the old one to Recently deleted.
- ``one_tab_per_profile``: a profile can be open in only one workspace tab (current
behaviour), or in several.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Literal
from bec_lib import bec_logger
from qtpy.QtCore import QDateTime, QObject, Qt, QTimeZone, Signal
from qtpy.QtGui import QPixmap
from bec_widgets.widgets.containers.dock_area.profile_utils import (
baseline_profile_candidates,
copy_profile,
get_profile_info,
has_saved_baseline,
is_profile_read_only,
list_profiles,
list_trashed_profiles,
load_baseline_profile_screenshot,
load_profile_screenshot,
open_runtime_settings,
profile_origin,
profile_origin_display,
restore_runtime_from_baseline,
restore_trashed_profile,
set_profile_notes,
set_quick_select,
trash_profile,
)
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
logger = bec_logger.logger
UI_ENV = "BEC_PROFILE_UI"
UiMode = Literal["legacy", "qwidget", "qml"]
NameMode = Literal["save", "rename", "duplicate"]
Tone = Literal["neutral", "info", "success", "warning", "error"]
INVALID_NAME_CHARS = '/\\:*?"<>|'
MAX_NAME_LENGTH = 64
def profile_ui_mode() -> UiMode:
"""Return which profile windows to use, from ``BEC_PROFILE_UI`` (default ``legacy``)."""
mode = os.environ.get(UI_ENV, "legacy").strip().lower()
return mode if mode in ("qwidget", "qml") else "legacy"
@dataclass
class ProfilePolicy:
"""Behaviour switches that are still open decisions; see the module docstring."""
rename_keeps_old_profile: bool = True
one_tab_per_profile: bool = True
POLICY = ProfilePolicy()
################################################################################
# Small formatting helpers
################################################################################
def relative_time(iso: str, now: QDateTime | None = None) -> str:
"""
Format an ISO 8601 UTC timestamp as a short relative time such as ``"3 h ago"``.
Args:
iso(str): Timestamp like ``2026-10-09T12:00:00Z``.
now(QDateTime | None): Reference time; defaults to the current UTC time.
Returns:
str: The relative time, or an empty string for an unreadable timestamp.
"""
stamp = QDateTime.fromString(iso or "", Qt.DateFormat.ISODate)
if not stamp.isValid():
return ""
now = now or QDateTime.currentDateTimeUtc()
seconds = max(0, stamp.secsTo(now))
if seconds < 60:
return "just now"
if seconds < 3600:
return f"{seconds // 60} min ago"
if seconds < 86400:
return f"{seconds // 3600} h ago"
days = seconds // 86400
if days == 1:
return "yesterday"
if days < 14:
return f"{days} days ago"
return stamp.toLocalTime().toString("d MMM yyyy")
def absolute_time(iso: str) -> str:
"""Format an ISO 8601 UTC timestamp in local time, e.g. ``"9 Oct 2026, 14:02"``."""
stamp = QDateTime.fromString(iso or "", Qt.DateFormat.ISODate)
if not stamp.isValid():
return ""
return stamp.toLocalTime().toString("d MMM yyyy, HH:mm")
def widget_count_label(count: int) -> str:
"""Return ``"1 widget"`` or ``"N widgets"``."""
return "1 widget" if count == 1 else f"{count} widgets"
def unique_name(base: str, taken, suffix: str = "copy") -> str:
"""
Return ``base_<suffix>``, ``base_<suffix>_2``, ... whichever is free first.
Args:
base(str): Name to start from.
taken(Callable[[str], bool]): Returns True when a name is in use.
suffix(str): Word appended to the name.
"""
base = (base or "profile").strip() or "profile"
candidate = f"{base}_{suffix}"
counter = 2
while taken(candidate):
candidate = f"{base}_{suffix}_{counter}"
counter += 1
return candidate
################################################################################
# Name validation
################################################################################
@dataclass
class NameCheck:
"""Result of :func:`check_profile_name`, rendered under the name field.
Attributes:
ok: Whether the confirm button is enabled.
tone: Colour of the hint line.
message: The hint line.
action: Label of the confirm button.
suggestion: A free name the user can pick with one click, if any.
replaces: Whether confirming replaces an existing user profile.
"""
ok: bool
tone: Tone
message: str
action: str
suggestion: str = ""
replaces: bool = False
def as_dict(self) -> dict:
"""Return the check as a plain dictionary for QML."""
return {
"ok": self.ok,
"tone": self.tone,
"message": self.message,
"action": self.action,
"suggestion": self.suggestion,
"replaces": self.replaces,
}
_ACTION_LABELS = {"save": "Save", "rename": "Rename", "duplicate": "Duplicate"}
def check_profile_name(
text: str,
mode: NameMode,
*,
namespace: str | None,
original: str | None = None,
current: str | None = None,
open_elsewhere=None,
) -> NameCheck:
"""
Validate a profile name while the user types it.
Args:
text(str): The name as typed.
mode(NameMode): ``save`` (save the current layout), ``rename`` or ``duplicate``.
namespace(str | None): Profile namespace.
original(str | None): The profile being renamed or duplicated.
current(str | None): The profile shown in the dock area the request comes from.
open_elsewhere(Callable[[str], bool] | None): Whether a profile is open in another tab.
Returns:
NameCheck: What to show and whether the name can be used.
"""
name = (text or "").strip()
action = _ACTION_LABELS[mode]
def taken(candidate: str) -> bool:
return profile_origin(candidate, namespace) != "unknown"
if not name:
return NameCheck(False, "neutral", "Enter a name for the profile.", action)
bad = sorted({c for c in name if c in INVALID_NAME_CHARS})
if bad or name.startswith("."):
shown = " ".join(bad) if bad else "a leading dot"
return NameCheck(False, "error", f"Names cannot contain {shown}.", action)
if len(name) > MAX_NAME_LENGTH:
return NameCheck(
False, "error", f"Use at most {MAX_NAME_LENGTH} characters ({len(name)} now).", action
)
if mode == "rename" and original and name == original:
return NameCheck(False, "neutral", "This is the current name.", action)
origin = profile_origin(name, namespace)
if origin in ("module", "plugin"):
provider = profile_origin_display(name, namespace) or "BEC"
return NameCheck(
False,
"error",
f"'{name}' is a read-only profile from {provider}. Pick another name.",
action,
suggestion=unique_name(name, taken, "custom"),
)
if (
POLICY.one_tab_per_profile
and open_elsewhere is not None
and name != current
and name != original
and open_elsewhere(name)
):
return NameCheck(
False,
"error",
f"'{name}' is open in another tab. Close that tab or pick another name.",
action,
suggestion=unique_name(name, taken),
)
if mode == "save" and current and name == current:
return NameCheck(True, "info", f"Updates the saved layout of '{name}'.", "Save")
if origin == "settings":
info = get_profile_info(name, namespace)
edited = relative_time(info.modified)
detail = f" (edited {edited})" if edited else ""
return NameCheck(
True,
"warning",
f"Replaces the saved profile '{name}'{detail}. "
"The old version goes to Recently deleted.",
"Replace",
replaces=True,
)
hints = {
"save": "Saves the current layout as a new profile.",
"rename": "",
"duplicate": f"Creates a copy of '{original}'." if original else "Creates a copy.",
}
if mode == "rename":
if original and POLICY.rename_keeps_old_profile:
hints["rename"] = f"Saves the layout as '{name}'. '{original}' stays in the library."
elif original:
hints["rename"] = (
f"Renames '{original}' to '{name}'. The old name goes to Recently deleted."
)
else:
hints["rename"] = f"Saves this workspace as '{name}'."
return NameCheck(True, "neutral", hints[mode], action)
################################################################################
# Actions
################################################################################
class ProfileActions:
"""
Profile operations used by the reworked windows, working on one dock area and, when the
dock area lives in workspace tabs, on all tabs.
Nothing is removed from disk: replaced and deleted profiles are moved to the profile
trash (Recently deleted), from where they can be restored.
Args:
dock_area(BECDockArea): The dock area the windows were opened from.
"""
def __init__(self, dock_area: BECDockArea):
self._origin_dock_area = dock_area
# -- context -----------------------------------------------------------------
@property
def host(self):
"""The :class:`WorkspaceTabs` hosting the dock area, or None."""
return getattr(self._origin_dock_area, "workspace_host", None)
@property
def namespace(self) -> str | None:
"""Profile namespace."""
return self._origin_dock_area.profile_namespace
def dock_area(self) -> BECDockArea:
"""The dock area actions apply to: the current tab when hosted in tabs."""
host = self.host
if host is not None:
current = host.current_dock_area()
if current is not None:
return current
return self._origin_dock_area
def current_profile(self) -> str | None:
"""Profile shown in :meth:`dock_area`."""
return getattr(self.dock_area(), "_current_profile_name", None)
def open_profiles(self) -> list[str]:
"""Profiles that are open in a tab (or in this dock area when not in tabs)."""
host = self.host
if host is not None:
return [name for name in host.workspace_names() if name]
current = self.current_profile()
return [current] if current else []
def open_elsewhere(self, name: str) -> bool:
"""Whether *name* is open in a tab other than the current one."""
return name != self.current_profile() and name in self.open_profiles()
def taken(self, name: str) -> bool:
"""Whether a profile with *name* exists."""
return profile_origin(name, self.namespace) != "unknown"
def check(self, text: str, mode: NameMode, original: str | None = None) -> NameCheck:
"""Validate *text* for *mode*; see :func:`check_profile_name`."""
return check_profile_name(
text,
mode,
namespace=self.namespace,
original=original,
current=self.current_profile(),
open_elsewhere=self.open_elsewhere,
)
def _dock_area_showing(self, name: str):
host = self.host
if host is not None:
for dock_area in host.dock_areas():
if dock_area._current_profile_name == name: # pylint: disable=protected-access
return dock_area
return None
dock_area = self._origin_dock_area
shown = dock_area._current_profile_name # pylint: disable=protected-access
return dock_area if shown == name else None
def _refresh_toolbars(self) -> None:
host = self.host
dock_areas = host.dock_areas() if host is not None else [self._origin_dock_area]
for dock_area in dock_areas:
dock_area._refresh_workspace_list() # pylint: disable=protected-access
def _flush(self, name: str) -> None:
"""Write the live layout of an open profile to its working copy."""
dock_area = self._dock_area_showing(name)
if dock_area is not None:
settings = open_runtime_settings(name, namespace=self.namespace)
dock_area._write_snapshot_to_settings(settings) # pylint: disable=protected-access
def _trash_for_replace(self, name: str) -> bool:
if self.taken(name) and not is_profile_read_only(name, self.namespace):
return trash_profile(name, self.namespace) is not None
return False
# -- operations --------------------------------------------------------------
def save_current(
self, name: str, *, notes: str | None = None, pinned: bool | None = None
) -> str:
"""
Save the layout of :meth:`dock_area` under *name*.
Returns:
str: A one-line summary for the confirmation banner.
"""
dock_area = self.dock_area()
current = self.current_profile()
replaced = name != current and self._trash_for_replace(name)
dock_area.save_profile(name, show_dialog=False, quick_select=pinned)
if notes is not None:
set_profile_notes(name, notes, self.namespace)
if replaced:
return f"Saved '{name}'. The version it replaced is in Recently deleted."
return f"Saved '{name}'."
def open(self, name: str, *, new_tab: bool = True) -> None:
"""Open *name* in a new tab (switching to it when open) or in the current tab."""
host = self.host
if host is not None and (new_tab or self.open_elsewhere(name)):
host.open_workspace(name)
return
self.dock_area().load_profile(name)
def set_pinned(self, name: str, pinned: bool) -> None:
"""Show or hide *name* in the toolbar profile list."""
set_quick_select(name, pinned, namespace=self.namespace)
self._refresh_toolbars()
def set_notes(self, name: str, notes: str) -> None:
"""Store the description of *name*."""
set_profile_notes(name, notes, self.namespace)
def duplicate(self, name: str, new_name: str) -> str:
"""Copy *name* to *new_name*; returns the banner text."""
self._flush(name)
replaced = self._trash_for_replace(new_name)
copy_profile(name, new_name, self.namespace)
suffix = " The version it replaced is in Recently deleted." if replaced else ""
return f"Created '{new_name}' from '{name}'.{suffix}"
def rename(self, name: str | None, new_name: str) -> str:
"""
Rename *name* to *new_name* following :data:`POLICY`; *name* None names the
unsaved workspace of the current tab. Returns the banner text.
"""
if name is None:
return self.save_current(new_name)
replaced = self._trash_for_replace(new_name)
dock_area = self._dock_area_showing(name)
host = self.host
if dock_area is not None and host is not None:
index = host.find_workspace(name)
host.rename_workspace(index, name=new_name, confirm=False)
elif dock_area is not None:
dock_area.save_profile(new_name, show_dialog=False)
else:
copy_profile(name, new_name, self.namespace)
moved_old = False
if not POLICY.rename_keeps_old_profile and not is_profile_read_only(name, self.namespace):
moved_old = trash_profile(name, self.namespace) is not None
self._refresh_toolbars()
parts = [f"Renamed '{name}' to '{new_name}'."]
if moved_old:
parts.append(f"'{name}' is in Recently deleted.")
elif POLICY.rename_keeps_old_profile:
parts.append(f"'{name}' is still in the library.")
if replaced:
parts.append("The profile it replaced is in Recently deleted.")
return " ".join(parts)
def can_delete(self, name: str) -> tuple[bool, str]:
"""Whether *name* can be moved to Recently deleted, and why not."""
if is_profile_read_only(name, self.namespace):
return False, "Read-only profiles cannot be deleted. Duplicate it to make changes."
if name in self.open_profiles():
return False, "Open in a tab. Close the tab to delete the profile."
return True, ""
def delete(self, name: str):
"""Move *name* to Recently deleted; returns the trash entry or None."""
ok, reason = self.can_delete(name)
if not ok:
raise ValueError(reason)
entry = trash_profile(name, self.namespace)
self._refresh_toolbars()
return entry
def restore(self, token: str) -> str:
"""Put a trashed profile back; returns its name."""
name = restore_trashed_profile(token, self.namespace)
self._refresh_toolbars()
return name
def can_revert(self, name: str) -> bool:
"""Whether *name* has a saved copy to revert to."""
return has_saved_baseline(name, self.namespace)
def revert(self, name: str) -> None:
"""Replace the working copy of *name* with its saved copy (and reload it if open)."""
dock_area = self._dock_area_showing(name)
if dock_area is not None:
dock_area.restore_baseline_profile(name, show_dialog=False)
else:
restore_runtime_from_baseline(name, namespace=self.namespace)
def name_form(self, mode: NameMode, original: str = "") -> dict:
"""Initial values of the name dialog: ``initial`` text, ``notes`` and ``pinned``."""
if mode == "save":
current = self.current_profile()
info = get_profile_info(current, self.namespace) if current else None
return {
"initial": current or "",
"notes": info.notes if info else "",
"pinned": info.is_quick_select if info else True,
}
initial = unique_name(original, self.taken) if mode == "duplicate" else original
return {"initial": initial, "notes": "", "pinned": True}
def apply_name(
self, mode: NameMode, original: str, name: str, notes: str = "", pinned: bool = True
) -> str:
"""Run a confirmed save, rename or duplicate; returns the banner text."""
name = name.strip()
if mode == "save":
return self.save_current(name, notes=notes, pinned=pinned)
if mode == "rename":
return self.rename(original or None, name)
return self.duplicate(original, name)
def saved_at(self, name: str) -> str:
"""ISO timestamp of the saved copy of *name*, or an empty string."""
for path in baseline_profile_candidates(name, self.namespace):
if os.path.exists(path):
stamp = QDateTime.fromSecsSinceEpoch(int(os.path.getmtime(path)), QTimeZone.utc())
return stamp.toString("yyyy-MM-ddTHH:mm:ssZ")
return ""
# -- previews ----------------------------------------------------------------
def live_preview(self, name: str | None = None) -> QPixmap | None:
"""Screenshot of the dock area showing *name* (the current one by default)."""
dock_area = self.dock_area() if name is None else self._dock_area_showing(name)
if dock_area is None or not dock_area.isVisible():
return None
pixmap = QPixmap()
data = dock_area.screenshot_bytes()
if not data or not pixmap.loadFromData(bytes(data)):
return None
return pixmap
def preview(self, name: str) -> QPixmap | None:
"""Best preview of *name*: live when it is shown in the current tab, else stored."""
if name == self.current_profile():
live = self.live_preview()
if live is not None:
return live
return load_profile_screenshot(name, namespace=self.namespace)
def saved_preview(self, name: str) -> QPixmap | None:
"""Stored screenshot of the saved copy of *name*."""
return load_baseline_profile_screenshot(name, namespace=self.namespace)
################################################################################
# Library state
################################################################################
SECTION_MINE = "My profiles"
SECTION_BUNDLED = "Read-only profiles"
SECTION_TRASH = "Recently deleted"
ROW_KEYS = [
"key",
"kind",
"section",
"name",
"subtitle",
"readOnly",
"pinned",
"isOpen",
"isCurrent",
"selected",
"token",
]
@dataclass
class Banner:
"""A one-line confirmation shown at the top of the library, optionally with an action."""
tone: Tone
text: str
action: str = ""
action_id: str = ""
def as_dict(self) -> dict:
"""Return the banner as a plain dictionary for QML."""
return {"tone": self.tone, "text": self.text, "action": self.action, "id": self.action_id}
@dataclass
class LibraryState:
"""Everything the library views render."""
rows: list[dict] = field(default_factory=list)
selected: dict | None = None
query: str = ""
banner: Banner | None = None
pending_delete: str = ""
empty_text: str = ""
has_tabs: bool = False
total: int = 0
class ProfileLibraryController(QObject):
"""
View state of the profile library: rows with sections, the selected profile, the search
text, inline delete confirmation and the undo banner.
Args:
actions(ProfileActions): The profile operations.
parent(QObject | None): Parent object.
"""
changed = Signal()
preview_changed = Signal()
# Ask the view to show the name dialog: mode, original name
name_requested = Signal(str, str)
# Ask the view to confirm a revert: profile name
revert_requested = Signal(str)
def __init__(self, actions: ProfileActions, parent: QObject | None = None):
super().__init__(parent)
self.actions = actions
self.state = LibraryState(has_tabs=actions.host is not None)
self._selected_key = ""
self._entries: dict[str, dict] = {}
self.preview_revision = 0
self.refresh(select=actions.current_profile())
# -- building ----------------------------------------------------------------
def refresh(self, select: str | None = None) -> None:
"""Re-read the profiles from disk and rebuild the rows."""
namespace = self.actions.namespace
open_names = set(self.actions.open_profiles())
current = self.actions.current_profile()
entries: dict[str, dict] = {}
for name in list_profiles(namespace):
info = get_profile_info(name, namespace)
if name == current:
info.widget_count = len(self.actions.dock_area().dock_list())
edited = relative_time(info.modified)
subtitle = [widget_count_label(info.widget_count)]
if edited:
subtitle.append(f"edited {edited}")
entries[f"p:{name}"] = {
"key": f"p:{name}",
"kind": "bundled" if info.is_read_only else "user",
"section": SECTION_BUNDLED if info.is_read_only else SECTION_MINE,
"name": name,
"subtitle": " · ".join(subtitle),
"readOnly": info.is_read_only,
"pinned": info.is_quick_select,
"isOpen": name in open_names,
"isCurrent": name == current,
"token": "",
"info": info,
}
for entry in list_trashed_profiles(namespace):
deleted = relative_time(entry.deleted_at)
entries[f"t:{entry.token}"] = {
"key": f"t:{entry.token}",
"kind": "trash",
"section": SECTION_TRASH,
"name": entry.name,
"subtitle": f"deleted {deleted}" if deleted else "deleted",
"readOnly": False,
"pinned": False,
"isOpen": False,
"isCurrent": False,
"token": entry.token,
"info": entry,
}
self._entries = entries
if select:
self._selected_key = f"p:{select}"
if self._selected_key not in entries:
self._selected_key = next(
(k for k, e in entries.items() if e["kind"] != "trash"), next(iter(entries), "")
)
self.preview_revision += 1
self._rebuild()
def _matches(self, entry: dict) -> bool:
query = self.state.query.strip().lower()
if not query:
return True
info = entry["info"]
haystack = f"{entry['name']} {getattr(info, 'notes', '')}".lower()
return all(part in haystack for part in query.split())
def _rebuild(self) -> None:
order = {SECTION_MINE: 0, SECTION_BUNDLED: 1, SECTION_TRASH: 2}
visible = [e for e in self._entries.values() if self._matches(e)]
visible.sort(key=lambda e: (order[e["section"]], e["name"].lower()))
rows = []
for entry in visible:
row = {key: entry.get(key) for key in ROW_KEYS}
row["selected"] = entry["key"] == self._selected_key
rows.append(row)
self.state.rows = rows
self.state.total = sum(1 for e in self._entries.values() if e["kind"] != "trash")
if not rows:
self.state.empty_text = (
f"No profile matches '{self.state.query.strip()}'."
if self.state.query.strip()
else "No saved profiles yet. Save the current layout to create one."
)
else:
self.state.empty_text = ""
self.state.selected = self._details(self._entries.get(self._selected_key))
if self.state.pending_delete and (
not self.state.selected or self.state.selected["name"] != self.state.pending_delete
):
self.state.pending_delete = ""
self.changed.emit()
def _details(self, entry: dict | None) -> dict | None:
if entry is None:
return None
if entry["kind"] == "trash":
trashed = entry["info"]
return {
"key": entry["key"],
"kind": "trash",
"name": entry["name"],
"token": entry["token"],
"facts": [("Deleted", absolute_time(trashed.deleted_at))],
"notes": "",
"status": "In Recently deleted. Restore it to use it again.",
"readOnly": False,
"pinned": False,
"isOpen": False,
"isCurrent": False,
"canRevert": False,
"canDelete": False,
"deleteReason": "",
"source": "",
}
info = entry["info"]
name = entry["name"]
can_delete, reason = self.actions.can_delete(name)
source = profile_origin_display(name, self.actions.namespace) or ""
if entry["isCurrent"]:
status = "Shown in this tab" if self.state.has_tabs else "Currently loaded"
elif entry["isOpen"]:
status = "Open in another tab"
else:
status = ""
facts = [
("Widgets", str(info.widget_count)),
("Edited", absolute_time(info.modified)),
("Created", absolute_time(info.created)),
("Source", f"{source} (read-only)" if info.is_read_only else "Saved by you"),
]
return {
"key": entry["key"],
"kind": entry["kind"],
"name": name,
"token": "",
"facts": facts,
"notes": info.notes,
"status": status,
"readOnly": info.is_read_only,
"pinned": info.is_quick_select,
"isOpen": entry["isOpen"],
"isCurrent": entry["isCurrent"],
"canRevert": self.actions.can_revert(name),
"canDelete": can_delete,
"deleteReason": reason,
"source": source,
}
# -- view requests -----------------------------------------------------------
def selected_name(self) -> str | None:
"""Name of the selected profile (None for nothing or a trashed one)."""
selected = self.state.selected
if not selected or selected["kind"] == "trash":
return None
return selected["name"]
def preview_for_selected(self) -> QPixmap | None:
"""Preview of the selected profile."""
name = self.selected_name()
return self.actions.preview(name) if name else None
def select(self, key: str) -> None:
"""Select the row with *key*."""
if key in self._entries and key != self._selected_key:
self._selected_key = key
self.state.pending_delete = ""
self.preview_revision += 1
self._rebuild()
self.preview_changed.emit()
def move_selection(self, step: int) -> None:
"""Select the next (step > 0) or previous visible row."""
keys = [row["key"] for row in self.state.rows]
if not keys:
return
index = keys.index(self._selected_key) if self._selected_key in keys else -1
self.select(keys[max(0, min(len(keys) - 1, index + step))])
def set_query(self, query: str) -> None:
"""Filter the rows by name and description."""
self.state.query = query
self._rebuild()
keys = [row["key"] for row in self.state.rows]
if keys and self._selected_key not in keys:
self.select(keys[0])
def dismiss_banner(self) -> None:
"""Hide the banner."""
self.state.banner = None
self.changed.emit()
def _done(self, text: str, select: str | None = None, **banner) -> None:
self.state.banner = Banner(banner.pop("tone", "success"), text, **banner)
self.refresh(select=select)
self.preview_changed.emit()
def _fail(self, exc: Exception) -> None:
logger.warning(f"Profile library: {exc}")
self.state.banner = Banner("error", str(exc))
self.changed.emit()
def open_selected(self, new_tab: bool = True) -> None:
"""Open the selected profile in a new tab (or the current one)."""
name = self.selected_name()
if not name:
return
try:
self.actions.open(name, new_tab=new_tab)
except Exception as exc: # pylint: disable=broad-except
self._fail(exc)
return
where = "in a new tab" if new_tab and self.state.has_tabs else "here"
self._done(f"Opened '{name}' {where}.", select=name, tone="info")
def toggle_pin(self, key: str | None = None) -> None:
"""Toggle whether a profile shows in the toolbar list."""
entry = self._entries.get(key or self._selected_key)
if not entry or entry["kind"] == "trash":
return
pinned = not entry["pinned"]
self.actions.set_pinned(entry["name"], pinned)
state = "now shows" if pinned else "no longer shows"
self._done(f"'{entry['name']}' {state} in the toolbar list.", tone="info")
def save_notes(self, notes: str) -> None:
"""Store the description of the selected profile."""
name = self.selected_name()
selected = self.state.selected
if not name or selected is None or (selected["notes"] or "") == (notes or "").strip():
return
self.actions.set_notes(name, notes)
self.refresh()
def request_name(self, mode: NameMode) -> None:
"""Ask the view for a name: ``save`` (current layout), ``rename`` or ``duplicate``."""
original = "" if mode == "save" else (self.selected_name() or "")
if mode != "save" and not original:
return
self.name_requested.emit(mode, original)
def apply_name(self, mode: NameMode, original: str, name: str, **options) -> bool:
"""Run the confirmed save, rename or duplicate."""
name = name.strip()
try:
text = self.actions.apply_name(mode, original, name, **options)
except Exception as exc: # pylint: disable=broad-except
self._fail(exc)
return False
self._done(text, select=name)
return True
def request_revert(self) -> None:
"""Ask the view to confirm reverting the selected profile."""
name = self.selected_name()
if name and self.actions.can_revert(name):
self.revert_requested.emit(name)
def apply_revert(self, name: str) -> None:
"""Revert *name* to its saved copy."""
try:
self.actions.revert(name)
except Exception as exc: # pylint: disable=broad-except
self._fail(exc)
return
self._done(f"Reverted '{name}' to its saved layout.", select=name)
def request_delete(self) -> None:
"""Show the inline delete confirmation for the selected profile."""
name = self.selected_name()
if name and self.actions.can_delete(name)[0]:
self.state.pending_delete = name
self.changed.emit()
def cancel_delete(self) -> None:
"""Hide the inline delete confirmation."""
self.state.pending_delete = ""
self.changed.emit()
def confirm_delete(self) -> None:
"""Move the profile awaiting confirmation to Recently deleted."""
name = self.state.pending_delete
self.state.pending_delete = ""
if not name:
return
try:
entry = self.actions.delete(name)
except Exception as exc: # pylint: disable=broad-except
self._fail(exc)
return
if entry is None:
self._fail(ValueError(f"No files of '{name}' were found."))
return
self._done(
f"Moved '{name}' to Recently deleted.",
action="Undo",
action_id=f"restore:{entry.token}",
)
def restore(self, token: str | None = None) -> None:
"""Restore a trashed profile (the selected one by default)."""
if token is None:
selected = self.state.selected
token = selected["token"] if selected and selected["kind"] == "trash" else ""
if not token:
return
try:
name = self.actions.restore(token)
except Exception as exc: # pylint: disable=broad-except
self._fail(exc)
return
self._done(f"Restored '{name}'.", select=name)
def banner_action(self) -> None:
"""Run the action of the banner (e.g. Undo)."""
banner = self.state.banner
if banner is None or not banner.action_id:
return
kind, _, value = banner.action_id.partition(":")
if kind == "restore":
self.restore(value)
@@ -0,0 +1,449 @@
"""Profile library, name dialog and revert dialog rendered with Qt Quick (QML).
Same UX as :mod:`profile_qwidget`; state and rules come from :mod:`.common`.
"""
# QML properties below read the backend's private state through lambdas.
# pylint: disable=protected-access
from __future__ import annotations
from pathlib import Path
from qtpy.QtCore import Property, QObject, QSize, Qt, Signal, Slot
from qtpy.QtGui import QPixmap
from qtpy.QtQuick import QQuickImageProvider
from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget
from bec_widgets.utils.quick import (
DictListModel,
create_quick_widget,
quick_engine,
release_quick_widget,
)
from bec_widgets.widgets.containers.dock_area.profile_utils import is_profile_read_only
from bec_widgets.widgets.containers.dock_area.profile_ux.common import (
ROW_KEYS,
NameMode,
ProfileActions,
ProfileLibraryController,
absolute_time,
relative_time,
)
QML_DIR = Path(__file__).parent / "qml"
PROVIDER_ID = "bwprofile"
TITLES = {"save": "Save profile", "rename": "Rename profile", "duplicate": "Duplicate profile"}
class PreviewProvider(QQuickImageProvider):
"""Serve profile screenshots to QML as ``image://bwprofile/<key>?<revision>``."""
def __init__(self):
super().__init__(QQuickImageProvider.ImageType.Pixmap)
self._pixmaps: dict[str, QPixmap] = {}
self._revision = 0
def publish(self, key: str, pixmap: QPixmap | None) -> str:
"""Store *pixmap* under *key* and return its URL, or "" when there is none."""
if pixmap is None or pixmap.isNull():
self._pixmaps.pop(key, None)
return ""
self._revision += 1
self._pixmaps[key] = pixmap
return f"image://{PROVIDER_ID}/{key}?{self._revision}"
def requestPixmap(
self, image_id: str, size: QSize, requested: QSize
) -> QPixmap: # pylint: disable=invalid-name
pixmap = self._pixmaps.get(image_id.partition("?")[0], QPixmap())
if requested.isValid() and not pixmap.isNull() and requested.width() > 0:
pixmap = pixmap.scaled(
requested,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
if size is not None:
size.setWidth(pixmap.width())
size.setHeight(pixmap.height())
return pixmap
_PROVIDER: PreviewProvider | None = None
def preview_provider() -> PreviewProvider:
"""Return the preview image provider, registering it with the shared QML engine."""
global _PROVIDER # pylint: disable=global-statement
engine = quick_engine()
if _PROVIDER is None or engine.imageProvider(PROVIDER_ID) is None:
_PROVIDER = PreviewProvider()
engine.addImageProvider(PROVIDER_ID, _PROVIDER)
return _PROVIDER
class _QuickDialog(QDialog):
"""QDialog showing one QML file with a backend object."""
def __init__(self, qml_file: str, backend: QObject, parent: QWidget | None, title: str):
super().__init__(parent)
self.setWindowTitle(title)
self.backend = backend
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.view = create_quick_widget(self, QML_DIR / qml_file, {"backend": backend})
self.view.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
layout.addWidget(self.view)
self.view.setFocus()
def done(self, result: int) -> None: # pylint: disable=invalid-name
"""Unload the scene before the backend goes away."""
release_quick_widget(self.view)
super().done(result)
################################################################################
# Name dialog
################################################################################
class NameBackend(QObject):
"""Backend of ``ProfileNameDialog.qml``."""
changed = Signal()
accepted = Signal()
rejected = Signal()
def __init__(
self,
actions: ProfileActions,
mode: NameMode,
original: str,
form: dict,
preview: QPixmap | None,
parent: QObject | None = None,
):
super().__init__(parent)
self.actions = actions
self._mode = mode
self._original = original
self._name = form["initial"]
self._notes = form["notes"]
self._pinned = form["pinned"]
self._preview = preview_provider().publish(f"name-{id(self)}", preview)
self._check = actions.check(self._name, mode, original or None).as_dict()
# pylint: disable=invalid-name,missing-function-docstring
@Slot(str)
def setName(self, text: str) -> None:
self._name = text
self._check = self.actions.check(text, self._mode, self._original or None).as_dict()
self.changed.emit()
@Slot(str)
def setNotes(self, text: str) -> None:
self._notes = text
@Slot(bool)
def setPinned(self, value: bool) -> None:
self._pinned = value
@Slot()
def confirm(self) -> None:
if self._check["ok"]:
self.accepted.emit()
@Slot()
def cancel(self) -> None:
self.rejected.emit()
def name(self) -> str:
return self._name.strip()
def notes(self) -> str:
return self._notes.strip()
def pinned(self) -> bool:
return bool(self._pinned)
mode = Property(str, lambda self: self._mode, constant=True)
heading = Property(
str,
lambda self: (
TITLES[self._mode]
if self._mode == "save"
else f"{TITLES[self._mode]} '{self._original}'"
),
constant=True,
)
initialName = Property(str, lambda self: self._name, constant=True)
initialNotes = Property(str, lambda self: self._notes, constant=True)
initialPinned = Property(bool, lambda self: bool(self._pinned), constant=True)
previewUrl = Property(str, lambda self: self._preview, constant=True)
check = Property("QVariantMap", lambda self: self._check, notify=changed)
class ProfileNameDialog(_QuickDialog):
"""QML version of the save, rename and duplicate dialog."""
def __init__(
self,
actions: ProfileActions,
mode: NameMode,
original: str = "",
form: dict | None = None,
preview: QPixmap | None = None,
parent: QWidget | None = None,
):
form = form or actions.name_form(mode, original)
backend = NameBackend(actions, mode, original, form, preview)
super().__init__("ProfileNameDialog.qml", backend, parent, TITLES[mode])
backend.setParent(self)
self.setModal(True)
self.resize(620, 380 if mode == "save" else 270)
backend.accepted.connect(self.accept)
backend.rejected.connect(self.reject)
def profile_name(self) -> str:
"""The entered name."""
return self.backend.name()
def notes(self) -> str:
"""The entered description."""
return self.backend.notes()
def pinned(self) -> bool:
"""Whether the profile shows in the toolbar list."""
return self.backend.pinned()
################################################################################
# Revert dialog
################################################################################
class RevertBackend(QObject):
"""Backend of ``RevertProfileDialog.qml``."""
accepted = Signal()
rejected = Signal()
def __init__(self, name: str, saved_at: str, current, saved, read_only: bool, parent=None):
super().__init__(parent)
provider = preview_provider()
self._name = name
when = relative_time(saved_at)
what = "its original layout" if read_only else "the layout saved"
text = f"Changes made since then are discarded. You go back to {what}"
self._text = text + (f" {when}." if when and not read_only else ".")
self._saved_caption = absolute_time(saved_at) or "Saved layout"
self._now = provider.publish(f"revert-now-{id(self)}", current)
self._saved = provider.publish(f"revert-saved-{id(self)}", saved)
# pylint: disable=invalid-name,missing-function-docstring
@Slot()
def confirm(self) -> None:
self.accepted.emit()
@Slot()
def cancel(self) -> None:
self.rejected.emit()
name = Property(str, lambda self: self._name, constant=True)
text = Property(str, lambda self: self._text, constant=True)
savedCaption = Property(str, lambda self: self._saved_caption, constant=True)
nowUrl = Property(str, lambda self: self._now, constant=True)
savedUrl = Property(str, lambda self: self._saved, constant=True)
class RevertProfileDialog(_QuickDialog):
"""QML version of the revert confirmation."""
def __init__(self, name, saved_at, current, saved, read_only=False, parent=None):
backend = RevertBackend(name, saved_at, current, saved, read_only)
super().__init__("RevertProfileDialog.qml", backend, parent, "Revert to saved layout")
backend.setParent(self)
self.setModal(True)
self.resize(720, 400)
backend.accepted.connect(self.accept)
backend.rejected.connect(self.reject)
################################################################################
# Library
################################################################################
class LibraryBackend(QObject):
"""Bridge between :class:`ProfileLibraryController` and ``ProfileLibrary.qml``."""
changed = Signal()
previewChanged = Signal()
def __init__(self, controller: ProfileLibraryController, parent: QObject | None = None):
super().__init__(parent)
self.controller = controller
self._rows = DictListModel(ROW_KEYS, self)
self._state: dict = {}
self._preview = ""
controller.changed.connect(self._sync)
controller.preview_changed.connect(self._sync_preview)
self._sync()
self._sync_preview()
def _sync(self) -> None:
state = self.controller.state
self._rows.set_items(state.rows)
selected = dict(state.selected or {})
if selected:
selected["facts"] = [{"label": k, "value": v} for k, v in selected["facts"]]
self._state = {
"selected": selected,
"query": state.query,
"banner": state.banner.as_dict() if state.banner else {},
"pendingDelete": state.pending_delete,
"emptyText": state.empty_text,
"hasTabs": state.has_tabs,
"total": state.total,
}
self.changed.emit()
def _sync_preview(self) -> None:
self._preview = preview_provider().publish(
f"library-{id(self)}", self.controller.preview_for_selected()
)
self.previewChanged.emit()
# pylint: disable=invalid-name,missing-function-docstring
@Slot(str)
def select(self, key: str) -> None:
self.controller.select(key)
@Slot(int)
def moveSelection(self, step: int) -> None:
self.controller.move_selection(step)
@Slot(str)
def setQuery(self, text: str) -> None:
self.controller.set_query(text)
@Slot(bool)
def openSelected(self, new_tab: bool) -> None:
self.controller.open_selected(new_tab)
@Slot(str)
def togglePin(self, key: str) -> None:
self.controller.toggle_pin(key)
@Slot(str)
def saveNotes(self, text: str) -> None:
self.controller.save_notes(text)
@Slot(str)
def requestName(self, mode: str) -> None:
self.controller.request_name(mode)
@Slot()
def requestRevert(self) -> None:
self.controller.request_revert()
@Slot()
def requestDelete(self) -> None:
self.controller.request_delete()
@Slot()
def cancelDelete(self) -> None:
self.controller.cancel_delete()
@Slot()
def confirmDelete(self) -> None:
self.controller.confirm_delete()
@Slot(str)
def restore(self, token: str) -> None:
self.controller.restore(token or None)
@Slot()
def bannerAction(self) -> None:
self.controller.banner_action()
@Slot()
def dismissBanner(self) -> None:
self.controller.dismiss_banner()
rows = Property(QObject, lambda self: self._rows, constant=True)
state = Property("QVariantMap", lambda self: self._state, notify=changed)
previewUrl = Property(str, lambda self: self._preview, notify=previewChanged)
class ProfileLibraryDialog(QDialog):
"""Non-modal window hosting ``ProfileLibrary.qml``."""
def __init__(self, actions: ProfileActions, parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle("Profile library")
self.setModal(False)
self.resize(1040, 640)
self.setMinimumSize(820, 520)
self.controller = ProfileLibraryController(actions, self)
self.backend = LibraryBackend(self.controller, self)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.view = create_quick_widget(
self, QML_DIR / "ProfileLibrary.qml", {"backend": self.backend}
)
self.view.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
layout.addWidget(self.view)
self.view.setFocus()
self.controller.name_requested.connect(self._ask_name)
self.controller.revert_requested.connect(self._ask_revert)
def done(self, result: int) -> None: # pylint: disable=invalid-name
"""Unload the scene before the backend goes away."""
release_quick_widget(self.view)
super().done(result)
def _ask_name(self, mode: str, original: str) -> None:
actions = self.controller.actions
preview = actions.live_preview() if mode == "save" else actions.preview(original)
dialog = ProfileNameDialog(actions, mode, original, None, preview, self)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
self.controller.apply_name(
mode, original, dialog.profile_name(), notes=dialog.notes(), pinned=dialog.pinned()
)
def _ask_revert(self, name: str) -> None:
if run_revert_flow(self.controller.actions, name, self):
self.controller.apply_revert(name)
def run_name_flow(
actions: ProfileActions, mode: NameMode, original: str = "", parent: QWidget | None = None
) -> str | None:
"""
Show the QML name dialog and run the save, rename or duplicate.
Returns:
str | None: The new name, or None when cancelled.
"""
preview = actions.preview(original) if mode == "duplicate" else actions.live_preview()
dialog = ProfileNameDialog(actions, mode, original, None, preview, parent)
if dialog.exec() != QDialog.DialogCode.Accepted:
return None
actions.apply_name(mode, original, dialog.profile_name(), dialog.notes(), dialog.pinned())
return dialog.profile_name()
def run_revert_flow(actions: ProfileActions, name: str, parent: QWidget | None = None) -> bool:
"""Ask whether to revert *name*; returns True when confirmed (the caller reverts)."""
current = actions.live_preview(name) or actions.preview(name)
dialog = RevertProfileDialog(
name,
actions.saved_at(name),
current,
actions.saved_preview(name),
is_profile_read_only(name, actions.namespace),
parent,
)
return dialog.exec() == QDialog.DialogCode.Accepted
@@ -0,0 +1,940 @@
"""Profile library, name dialog and revert dialog built from QWidgets.
Same UX as :mod:`profile_qml`; state and rules come from :mod:`.common`.
"""
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import QRectF, QSize, Qt, QTimer, Signal
from qtpy.QtGui import QColor, QKeySequence, QPainter, QPainterPath, QPixmap, QShortcut
from qtpy.QtWidgets import (
QDialog,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.quick.host import ThemeTokens
from bec_widgets.utils.ux_kit import (
IconButton,
StatusPill,
TextButton,
ToggleSwitch,
field_qss,
rgba,
set_invalid,
)
from bec_widgets.widgets.containers.dock_area.profile_utils import is_profile_read_only
from bec_widgets.widgets.containers.dock_area.profile_ux.common import (
NameCheck,
NameMode,
ProfileActions,
ProfileLibraryController,
absolute_time,
relative_time,
)
TITLES = {"save": "Save profile", "rename": "Rename profile", "duplicate": "Duplicate profile"}
def tone_color(tokens: ThemeTokens, tone: str) -> QColor:
"""Colour of a hint or banner tone."""
return {
"error": tokens.danger,
"warning": tokens.warning,
"success": tokens.success,
"info": tokens.primary,
}.get(tone, tokens.fg_muted)
TONE_ICONS = {
"error": "error",
"warning": "warning",
"success": "check_circle",
"info": "info",
"neutral": "",
}
class PreviewFrame(QWidget):
"""Rounded screenshot preview that keeps the aspect ratio, with a placeholder text."""
def __init__(self, parent: QWidget | None = None, placeholder: str = "No preview yet"):
super().__init__(parent)
self._pixmap: QPixmap | None = None
self._placeholder = placeholder
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setMinimumSize(160, 100)
def set_pixmap(self, pixmap: QPixmap | None, placeholder: str | None = None) -> None:
"""Show *pixmap*, or the placeholder when it is None."""
self._pixmap = pixmap if pixmap is not None and not pixmap.isNull() else None
if placeholder is not None:
self._placeholder = placeholder
self.update()
def has_pixmap(self) -> bool:
"""Whether a screenshot is shown."""
return self._pixmap is not None
def sizeHint(self) -> QSize: # pylint: disable=invalid-name
"""Preferred preview size."""
return QSize(320, 200)
def paintEvent(self, _event): # pylint: disable=invalid-name
"""Paint the screenshot or the placeholder."""
tokens = ThemeTokens()
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
if self._pixmap is not None:
scaled = self._pixmap.size().scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio)
target = QRectF(
(self.width() - scaled.width()) / 2,
(self.height() - scaled.height()) / 2,
scaled.width(),
scaled.height(),
)
path = QPainterPath()
path.addRoundedRect(target, 8, 8)
painter.setClipPath(path)
painter.drawPixmap(target.toRect(), self._pixmap)
painter.setClipping(False)
painter.setPen(tokens.border)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRoundedRect(target.adjusted(0.5, 0.5, -0.5, -0.5), 8, 8)
else:
painter.setPen(tokens.border)
painter.setBrush(tokens.soft(tokens.fg, 0.04))
painter.drawRoundedRect(rect, 8, 8)
icon = material_icon("dashboard", size=(64, 64), color=tokens.fg_subtle)
icon_rect = QRectF(rect.center().x() - 16, rect.center().y() - 28, 32, 32)
painter.drawPixmap(icon_rect.toRect(), icon)
painter.setPen(tokens.fg_subtle)
font = self.font()
font.setPixelSize(12)
painter.setFont(font)
painter.drawText(
rect.adjusted(8, 40, -8, 0),
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter,
self._placeholder,
)
painter.end()
class HintLine(QWidget):
"""Icon and text in the colour of a tone, used under fields and in banners."""
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
self.icon = QLabel(self)
self.icon.setFixedSize(16, 16)
self.text = QLabel(self)
self.text.setWordWrap(True)
self.text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
layout.addWidget(self.icon, 0, Qt.AlignmentFlag.AlignTop)
layout.addWidget(self.text, 1)
self._tone = "neutral"
def set_hint(self, tone: str, text: str) -> None:
"""Show *text* in the colour of *tone*."""
self._tone = tone
tokens = ThemeTokens()
color = tone_color(tokens, tone)
icon = TONE_ICONS.get(tone, "")
self.icon.setVisible(bool(icon))
if icon:
self.icon.setPixmap(material_icon(icon, size=(32, 32), color=color).scaled(16, 16))
text_color = tokens.fg_muted if tone == "neutral" else color
if tone == "warning" and not tokens.dark:
text_color = tokens.warning.darker(160)
self.text.setStyleSheet(f"color: {text_color.name()}; font-size: 12px;")
self.text.setText(text)
def _label(text: str, role: str = "body", parent: QWidget | None = None) -> QLabel:
tokens = ThemeTokens()
label = QLabel(text, parent)
styles = {
"title": f"color: {tokens.fg.name()}; font-size: 16px; font-weight: 600;",
"heading": f"color: {tokens.fg.name()}; font-size: 13px; font-weight: 600;",
"body": f"color: {tokens.fg.name()}; font-size: 13px;",
"muted": f"color: {tokens.fg_muted.name()}; font-size: 12px;",
"caption": (
f"color: {tokens.fg_subtle.name()}; font-size: 11px; font-weight: 600;"
" letter-spacing: 0.5px;"
),
}
label.setStyleSheet(styles[role])
return label
################################################################################
# Name dialog
################################################################################
class ProfileNameDialog(QDialog):
"""
One dialog for saving, renaming and duplicating profiles. The name is checked while
typing; conflicts are explained under the field instead of in follow-up message boxes.
Args:
actions(ProfileActions): Profile operations and checks.
mode(NameMode): ``save``, ``rename`` or ``duplicate``.
original(str): Profile being renamed or duplicated.
initial(str): Initial text of the name field.
preview(QPixmap | None): Screenshot shown next to the form.
parent(QWidget | None): Parent widget.
"""
def __init__(
self,
actions: ProfileActions,
mode: NameMode,
original: str = "",
initial: str = "",
preview: QPixmap | None = None,
parent: QWidget | None = None,
notes: str = "",
pinned: bool = True,
):
super().__init__(parent)
self.actions = actions
self.mode = mode
self.original = original
self._check: NameCheck | None = None
tokens = ThemeTokens()
self.setWindowTitle(TITLES[mode])
self.setModal(True)
self.setObjectName("ProfileNameDialog")
self.setStyleSheet(
f"QDialog#ProfileNameDialog {{ background: {tokens.card.name()}; }}" + field_qss(tokens)
)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 16)
root.setSpacing(14)
heading = TITLES[mode] if mode == "save" else f"{TITLES[mode]} '{original}'"
root.addWidget(_label(heading, "title", self))
body = QHBoxLayout()
body.setSpacing(16)
self.preview = PreviewFrame(self, "Nothing to preview")
self.preview.setFixedSize(220, 140)
self.preview.set_pixmap(preview)
body.addWidget(self.preview, 0, Qt.AlignmentFlag.AlignTop)
form = QVBoxLayout()
form.setSpacing(6)
form.addWidget(_label("Name", "heading", self))
self.name_edit = QLineEdit(initial, self)
self.name_edit.setPlaceholderText("e.g. alignment, overview, tomo_scan")
self.name_edit.setMinimumWidth(300)
self.name_edit.selectAll()
form.addWidget(self.name_edit)
self.hint = HintLine(self)
form.addWidget(self.hint)
self.suggestion_button = TextButton("", "ghost", "auto_fix_high", self)
self.suggestion_button.setMinimumHeight(26)
self.suggestion_button.clicked.connect(self._use_suggestion)
form.addWidget(self.suggestion_button, 0, Qt.AlignmentFlag.AlignLeft)
self.notes_edit = QLineEdit(notes, self)
self.pin_switch = ToggleSwitch(self)
self.pin_switch.setChecked(pinned)
if mode == "save":
form.addSpacing(6)
form.addWidget(_label("Description", "heading", self))
self.notes_edit.setPlaceholderText("Optional, shown in the profile library")
form.addWidget(self.notes_edit)
form.addSpacing(6)
pin_row = QHBoxLayout()
pin_text = QVBoxLayout()
pin_text.setSpacing(0)
pin_text.addWidget(_label("Show in toolbar list", "heading", self))
pin_text.addWidget(_label("Quick access from the profile drop-down.", "muted", self))
pin_row.addLayout(pin_text, 1)
pin_row.addWidget(self.pin_switch)
form.addLayout(pin_row)
else:
self.notes_edit.hide()
self.pin_switch.hide()
form.addStretch(1)
body.addLayout(form, 1)
root.addLayout(body)
buttons = QHBoxLayout()
buttons.addStretch(1)
self.cancel_button = TextButton("Cancel", "neutral", parent=self)
self.confirm_button = TextButton(TITLES[mode].split()[0], "primary", parent=self)
self.cancel_button.clicked.connect(self.reject)
self.confirm_button.clicked.connect(self._confirm)
self.confirm_button.setDefault(True)
buttons.addWidget(self.cancel_button)
buttons.addWidget(self.confirm_button)
root.addLayout(buttons)
self.name_edit.textChanged.connect(self._validate)
self.name_edit.returnPressed.connect(self._confirm)
self._validate()
@property
def check(self) -> NameCheck:
"""The latest validation result."""
return self._check
def profile_name(self) -> str:
"""The entered name."""
return self.name_edit.text().strip()
def notes(self) -> str:
"""The entered description."""
return self.notes_edit.text().strip()
def pinned(self) -> bool:
"""Whether the profile shows in the toolbar list."""
return self.pin_switch.isChecked()
def _validate(self, *_args) -> None:
check = self.actions.check(self.name_edit.text(), self.mode, self.original or None)
self._check = check
self.hint.set_hint(check.tone, check.message)
self.hint.setVisible(bool(check.message))
set_invalid(self.name_edit, check.tone == "error")
self.suggestion_button.setVisible(bool(check.suggestion))
if check.suggestion:
self.suggestion_button.setText(f"Use '{check.suggestion}'")
self.confirm_button.setText(check.action)
self.confirm_button.set_variant("danger" if check.replaces else "primary")
self.confirm_button.setEnabled(check.ok)
def _use_suggestion(self) -> None:
if self._check and self._check.suggestion:
self.name_edit.setText(self._check.suggestion)
self.name_edit.setFocus()
def _confirm(self) -> None:
if self._check is not None and self._check.ok:
self.accept()
################################################################################
# Revert dialog
################################################################################
class RevertProfileDialog(QDialog):
"""
Confirm reverting a profile to its saved layout, showing the current and the saved
layout side by side. Cancel is the default button.
Args:
name(str): Profile name.
saved_at(str): ISO timestamp of the saved copy.
current(QPixmap | None): Screenshot of the current layout.
saved(QPixmap | None): Screenshot of the saved layout.
read_only(bool): Whether the profile is a read-only one (reverts to its original).
parent(QWidget | None): Parent widget.
"""
def __init__(
self,
name: str,
saved_at: str,
current: QPixmap | None,
saved: QPixmap | None,
read_only: bool = False,
parent: QWidget | None = None,
):
super().__init__(parent)
tokens = ThemeTokens()
self.setWindowTitle("Revert to saved layout")
self.setModal(True)
self.setObjectName("RevertProfileDialog")
self.setStyleSheet(f"QDialog#RevertProfileDialog {{ background: {tokens.card.name()}; }}")
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 16)
root.setSpacing(12)
root.addWidget(_label(f"Revert '{name}' to its saved layout?", "title", self))
when = relative_time(saved_at)
what = "its original layout" if read_only else "the layout saved"
text = f"Changes made since then are discarded. You go back to {what}"
text += f" {when}." if when and not read_only else "."
body = _label(text, "muted", self)
body.setWordWrap(True)
root.addWidget(body)
row = QHBoxLayout()
row.setSpacing(12)
for caption, pixmap, sub in (
("NOW", current, "Current layout"),
("AFTER REVERT", saved, absolute_time(saved_at) or "Saved layout"),
):
column = QVBoxLayout()
column.setSpacing(6)
column.addWidget(_label(caption, "caption", self))
frame = PreviewFrame(self, "No screenshot")
frame.setFixedSize(300, 190)
frame.set_pixmap(pixmap)
column.addWidget(frame)
column.addWidget(_label(sub, "muted", self))
row.addLayout(column)
if caption == "NOW":
arrow = QLabel(self)
arrow.setPixmap(
material_icon("arrow_forward", size=(40, 40), color=tokens.fg_subtle).scaled(
20, 20
)
)
row.addWidget(arrow, 0, Qt.AlignmentFlag.AlignVCenter)
root.addLayout(row)
buttons = QHBoxLayout()
buttons.addStretch(1)
self.cancel_button = TextButton("Keep current layout", "neutral", parent=self)
self.revert_button = TextButton("Revert", "danger", "history", self)
self.cancel_button.setDefault(True)
self.cancel_button.clicked.connect(self.reject)
self.revert_button.clicked.connect(self.accept)
buttons.addWidget(self.cancel_button)
buttons.addWidget(self.revert_button)
root.addLayout(buttons)
################################################################################
# Library
################################################################################
class _Row(QFrame):
"""One profile row of the library list."""
pin_clicked = Signal(str)
restore_clicked = Signal(str)
def __init__(self, row: dict, parent: QWidget | None = None):
super().__init__(parent)
tokens = ThemeTokens()
self.key = row["key"]
self.setObjectName("libRow")
self._selected = None
self.set_selected(row["selected"])
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 6, 6, 6)
layout.setSpacing(8)
icon_name = {"trash": "delete", "bundled": "lock"}.get(row["kind"], "dashboard")
icon = QLabel(self)
icon_color = tokens.primary if row["isCurrent"] else tokens.fg_subtle
icon.setPixmap(material_icon(icon_name, size=(36, 36), color=icon_color).scaled(18, 18))
icon.setStyleSheet("background: none; border: none;")
layout.addWidget(icon)
text = QVBoxLayout()
text.setSpacing(1)
name = QLabel(row["name"], self)
weight = 600 if row["isCurrent"] else 500
fg = tokens.fg_muted if row["kind"] == "trash" else tokens.fg
name.setStyleSheet(
f"color: {fg.name()}; font-size: 13px; font-weight: {weight}; background: none;"
" border: none;"
)
subtitle = QLabel(row["subtitle"], self)
subtitle.setStyleSheet(
f"color: {tokens.fg_subtle.name()}; font-size: 11px; background: none; border: none;"
)
text.addWidget(name)
text.addWidget(subtitle)
layout.addLayout(text, 1)
if row["isCurrent"] or row["isOpen"]:
pill = StatusPill(self)
pill.set_status(
"This tab" if row["isCurrent"] else "Open",
tokens.primary if row["isCurrent"] else tokens.success,
)
layout.addWidget(pill)
if row["kind"] == "trash":
restore = TextButton("Restore", "ghost", "restore_from_trash", self)
restore.setMinimumHeight(28)
restore.clicked.connect(lambda: self.restore_clicked.emit(row["token"]))
layout.addWidget(restore)
else:
pin = IconButton("star", "Show in toolbar list", self, size=16)
pin.setIcon(
material_icon(
"star",
size=(32, 32),
filled=row["pinned"],
color=tokens.warning if row["pinned"] else tokens.fg_subtle,
convert_to_pixmap=False,
)
)
pin.setToolTip(
"Shown in the toolbar list. Click to hide."
if row["pinned"]
else "Not in the toolbar list. Click to show."
)
pin.clicked.connect(lambda: self.pin_clicked.emit(self.key))
layout.addWidget(pin)
def set_selected(self, selected: bool) -> None:
"""Show the row as selected or not."""
if selected == self._selected:
return
self._selected = selected
tokens = ThemeTokens()
bg = rgba(tokens.soft(tokens.primary, 0.14), 1.0) if selected else "transparent"
hover = bg if selected else tokens.hover.name()
border = tokens.primary.name() if selected else "transparent"
self.setStyleSheet(
f"QFrame#libRow {{ background: {bg}; border-radius: 8px; border: 1px solid {border}; }}"
f"QFrame#libRow:hover {{ background: {hover}; }}"
)
class ProfileLibraryWidget(QWidget):
"""
The profile library: searchable list with sections on the left, the selected profile's
preview, description, facts and actions on the right.
Args:
controller(ProfileLibraryController): State and actions.
parent(QWidget | None): Parent widget.
"""
def __init__(self, controller: ProfileLibraryController, parent: QWidget | None = None):
super().__init__(parent)
self.controller = controller
self.setObjectName("ProfileLibrary")
tokens = ThemeTokens()
self.setStyleSheet(
f"QWidget#ProfileLibrary {{ background: {tokens.bg.name()}; }}" + field_qss(tokens)
)
root = QVBoxLayout(self)
root.setContentsMargins(16, 14, 16, 14)
root.setSpacing(10)
# header
header = QHBoxLayout()
header.setSpacing(10)
title_col = QVBoxLayout()
title_col.setSpacing(0)
self.title = _label("Profiles", "title", self)
self.count_label = _label("", "muted", self)
title_col.addWidget(self.title)
title_col.addWidget(self.count_label)
header.addLayout(title_col)
header.addStretch(1)
self.search = QLineEdit(self)
self.search.setPlaceholderText("Search profiles (Ctrl+F)")
self.search.setClearButtonEnabled(True)
self.search.setFixedWidth(260)
self.search.addAction(
material_icon("search", size=(32, 32), color=tokens.fg_subtle, convert_to_pixmap=False),
QLineEdit.ActionPosition.LeadingPosition,
)
header.addWidget(self.search)
self.save_button = TextButton("Save current layout…", "primary", "save", self)
header.addWidget(self.save_button)
root.addLayout(header)
# banner
self.banner = QFrame(self)
self.banner.setObjectName("libBanner")
banner_layout = QHBoxLayout(self.banner)
banner_layout.setContentsMargins(12, 6, 6, 6)
self.banner_hint = HintLine(self.banner)
banner_layout.addWidget(self.banner_hint, 1)
self.banner_action = TextButton("", "ghost", parent=self.banner)
self.banner_action.setMinimumHeight(28)
banner_layout.addWidget(self.banner_action)
self.banner_close = IconButton("close", "Dismiss", self.banner, size=16)
banner_layout.addWidget(self.banner_close)
root.addWidget(self.banner)
# body
body = QHBoxLayout()
body.setSpacing(14)
self.list = QListWidget(self)
self.list.setFixedWidth(330)
self.list.setSpacing(1)
self.list.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
self.list.setStyleSheet(
f"QListWidget {{ background: {tokens.card.name()}; border: 1px solid"
f" {tokens.border.name()}; border-radius: 10px; padding: 6px; outline: none; }}"
"QListWidget::item, QListWidget::item:selected, QListWidget::item:hover"
" { background: transparent; border: none; }"
)
body.addWidget(self.list)
self.details = QFrame(self)
self.details.setObjectName("libDetails")
self.details.setStyleSheet(
f"QFrame#libDetails {{ background: {tokens.card.name()}; border: 1px solid"
f" {tokens.border.name()}; border-radius: 10px; }}"
)
details = QVBoxLayout(self.details)
details.setContentsMargins(16, 16, 16, 14)
details.setSpacing(10)
self.preview = PreviewFrame(self.details)
self.preview.setMinimumHeight(220)
details.addWidget(self.preview, 1)
name_row = QHBoxLayout()
self.name_label = _label("", "title", self.details)
self.status_pill = StatusPill(self.details)
name_row.addWidget(self.name_label)
name_row.addWidget(self.status_pill)
name_row.addStretch(1)
self.delete_button = TextButton("Delete…", "ghost", "delete", self.details)
name_row.addWidget(self.delete_button)
details.addLayout(name_row)
self.notes_edit = QLineEdit(self.details)
self.notes_edit.setPlaceholderText("Add a description…")
details.addWidget(self.notes_edit)
self.read_only_hint = HintLine(self.details)
details.addWidget(self.read_only_hint)
self.facts = QGridLayout()
self.facts.setHorizontalSpacing(18)
self.facts.setVerticalSpacing(4)
details.addLayout(self.facts)
actions = QHBoxLayout()
actions.setSpacing(8)
self.open_button = TextButton("Open in new tab", "primary", "tab", self.details)
self.open_here_button = TextButton("Open here", "neutral", "open_in_browser", self.details)
self.duplicate_button = TextButton("Duplicate…", "neutral", "content_copy", self.details)
self.rename_button = TextButton("Rename…", "neutral", "edit", self.details)
self.revert_button = TextButton("Revert…", "neutral", "history", self.details)
self.restore_button = TextButton("Restore", "primary", "restore_from_trash", self.details)
for button in (
self.open_button,
self.open_here_button,
self.restore_button,
self.duplicate_button,
self.rename_button,
self.revert_button,
):
actions.addWidget(button)
actions.addStretch(1)
details.addLayout(actions)
self.confirm = QFrame(self.details)
self.confirm.setObjectName("libConfirm")
confirm_layout = QHBoxLayout(self.confirm)
confirm_layout.setContentsMargins(12, 8, 8, 8)
self.confirm_text = HintLine(self.confirm)
confirm_layout.addWidget(self.confirm_text, 1)
self.confirm_cancel = TextButton("Cancel", "neutral", parent=self.confirm)
self.confirm_delete = TextButton(
"Move to Recently deleted", "danger", "delete", self.confirm
)
confirm_layout.addWidget(self.confirm_cancel)
confirm_layout.addWidget(self.confirm_delete)
details.addWidget(self.confirm)
body.addWidget(self.details, 1)
self.empty = _label("", "muted", self)
self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
root.addLayout(body, 1)
# wiring
self.search.textChanged.connect(controller.set_query)
self.search.installEventFilter(self)
self.list.currentItemChanged.connect(self._on_current_item)
self.list.itemDoubleClicked.connect(lambda *_: controller.open_selected(True))
self.save_button.clicked.connect(lambda: controller.request_name("save"))
self.open_button.clicked.connect(lambda: controller.open_selected(True))
self.open_here_button.clicked.connect(lambda: controller.open_selected(False))
self.duplicate_button.clicked.connect(lambda: controller.request_name("duplicate"))
self.rename_button.clicked.connect(lambda: controller.request_name("rename"))
self.revert_button.clicked.connect(controller.request_revert)
self.restore_button.clicked.connect(lambda: controller.restore(None))
self.delete_button.clicked.connect(controller.request_delete)
self.confirm_cancel.clicked.connect(controller.cancel_delete)
self.confirm_delete.clicked.connect(controller.confirm_delete)
self.banner_action.clicked.connect(controller.banner_action)
self.banner_close.clicked.connect(controller.dismiss_banner)
self.notes_edit.editingFinished.connect(
lambda: controller.save_notes(self.notes_edit.text())
)
controller.changed.connect(self.render)
controller.preview_changed.connect(self._render_preview)
for keys, slot in (
("Ctrl+F", self.search.setFocus),
("F2", lambda: controller.request_name("rename")),
("Ctrl+D", lambda: controller.request_name("duplicate")),
("Ctrl+S", lambda: controller.request_name("save")),
("Delete", controller.request_delete),
):
shortcut = QShortcut(QKeySequence(keys), self)
shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
shortcut.activated.connect(slot)
self._rendering = False
self._list_signature = None
self.render()
self._render_preview()
def eventFilter(self, obj, event): # pylint: disable=invalid-name
"""Let Up/Down/Enter in the search field drive the list."""
if obj is self.search and event.type() == event.Type.KeyPress:
if event.key() in (Qt.Key.Key_Down, Qt.Key.Key_Up):
self.controller.move_selection(1 if event.key() == Qt.Key.Key_Down else -1)
return True
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
self.controller.open_selected(True)
return True
return super().eventFilter(obj, event)
def _on_current_item(self, item: QListWidgetItem | None, _previous=None) -> None:
if self._rendering or item is None:
return
key = item.data(Qt.ItemDataRole.UserRole)
if key:
QTimer.singleShot(0, lambda: self.controller.select(key))
def _render_preview(self) -> None:
selected = self.controller.state.selected
if selected and selected["kind"] == "trash":
self.preview.set_pixmap(None, "Deleted profile. Restore it to open it.")
else:
self.preview.set_pixmap(self.controller.preview_for_selected(), "No preview yet")
def render(self) -> None:
"""Rebuild the list and the details from the controller state."""
state = self.controller.state
tokens = ThemeTokens()
self._render_list(state)
self._render_header(state, tokens)
self._render_details(state, tokens)
def _render_list(self, state) -> None:
signature = [tuple(v for k, v in row.items() if k != "selected") for row in state.rows]
signature.append(state.empty_text)
if signature == self._list_signature:
# only the selection moved: restyle the rows instead of rebuilding them
self._rendering = True
for index in range(self.list.count()):
item = self.list.item(index)
widget = self.list.itemWidget(item)
if isinstance(widget, _Row):
selected = widget.key == (state.selected or {}).get("key")
widget.set_selected(selected)
if selected:
self.list.setCurrentItem(item)
self.list.scrollToItem(item)
self._rendering = False
return
self._list_signature = signature
self._rendering = True
self.list.clear()
section = None
current_item = None
for row in state.rows:
if row["section"] != section:
section = row["section"]
header = QListWidgetItem(self.list)
header.setFlags(Qt.ItemFlag.NoItemFlags)
label = _label(section.upper(), "caption")
label.setContentsMargins(8, 10 if self.list.count() > 1 else 2, 0, 2)
header.setSizeHint(label.sizeHint())
self.list.setItemWidget(header, label)
item = QListWidgetItem(self.list)
item.setData(Qt.ItemDataRole.UserRole, row["key"])
widget = _Row(row)
widget.pin_clicked.connect(self.controller.toggle_pin)
widget.restore_clicked.connect(self.controller.restore)
item.setSizeHint(QSize(0, 48))
self.list.setItemWidget(item, widget)
if row["selected"]:
current_item = item
if not state.rows:
item = QListWidgetItem(self.list)
item.setFlags(Qt.ItemFlag.NoItemFlags)
label = _label(state.empty_text, "muted")
label.setWordWrap(True)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setContentsMargins(12, 30, 12, 30)
item.setSizeHint(QSize(0, 100))
self.list.setItemWidget(item, label)
if current_item is not None:
self.list.setCurrentItem(current_item)
self.list.scrollToItem(current_item)
self._rendering = False
def _render_header(self, state, tokens) -> None:
if self.search.text() != state.query and not self.search.hasFocus():
blocked = self.search.blockSignals(True)
self.search.setText(state.query)
self.search.blockSignals(blocked)
count = state.total
self.count_label.setText(f"{count} profile" + ("" if count == 1 else "s"))
self.save_button.setVisible(True)
banner = state.banner
self.banner.setVisible(banner is not None)
if banner is not None:
color = tone_color(tokens, banner.tone)
self.banner.setStyleSheet(
f"QFrame#libBanner {{ background: {tokens.soft(color, 0.14).name()};"
f" border: 1px solid {tokens.soft(color, 0.45).name()}; border-radius: 8px; }}"
)
self.banner_hint.set_hint(banner.tone, banner.text)
self.banner_action.setVisible(bool(banner.action))
self.banner_action.setText(banner.action)
def _render_details(self, state, tokens) -> None:
selected = state.selected
self.details.setVisible(selected is not None)
if selected is None:
return
is_trash = selected["kind"] == "trash"
self.name_label.setText(selected["name"])
status = selected["status"]
self.status_pill.setVisible(bool(status) and not is_trash)
if status:
self.status_pill.set_status(
status, tokens.primary if selected["isCurrent"] else tokens.success
)
if not self.notes_edit.hasFocus():
self.notes_edit.setText(selected["notes"] or "")
self.notes_edit.setVisible(not is_trash and not selected["readOnly"])
if is_trash:
self.read_only_hint.set_hint("neutral", status)
elif selected["readOnly"]:
self.read_only_hint.set_hint(
"info",
f"Read-only profile from {selected['source']}. Your changes are kept as you"
" work; Revert brings back the original. Duplicate it to keep your own version.",
)
self.read_only_hint.setVisible(is_trash or selected["readOnly"])
while self.facts.count():
item = self.facts.takeAt(0)
if item.widget():
item.widget().deleteLater()
for index, (key, value) in enumerate(selected["facts"]):
self.facts.addWidget(_label(key, "muted", self.details), index // 2, (index % 2) * 2)
self.facts.addWidget(
_label(value, "body", self.details), index // 2, (index % 2) * 2 + 1
)
self.facts.setColumnStretch(1, 1)
self.facts.setColumnStretch(3, 1)
has_tabs = state.has_tabs
self.open_button.setVisible(not is_trash and not selected["isCurrent"])
self.open_button.setText(
("Go to tab" if selected["isOpen"] else "Open in new tab") if has_tabs else "Open"
)
self.open_here_button.setVisible(not is_trash and has_tabs and not selected["isOpen"])
self.restore_button.setVisible(is_trash)
self.duplicate_button.setVisible(not is_trash)
self.rename_button.setVisible(not is_trash and not selected["readOnly"])
self.revert_button.setVisible(not is_trash)
self.revert_button.setEnabled(selected["canRevert"])
self.revert_button.setToolTip(
"Go back to the saved layout"
if selected["canRevert"]
else "Nothing saved to go back to"
)
self.delete_button.setVisible(not is_trash)
self.delete_button.setEnabled(selected["canDelete"])
self.delete_button.setToolTip(selected["deleteReason"] or "Move to Recently deleted")
pending = bool(state.pending_delete)
self.confirm.setVisible(pending)
if pending:
self.confirm.setStyleSheet(
f"QFrame#libConfirm {{ background: {tokens.soft(tokens.danger, 0.12).name()};"
f" border: 1px solid {tokens.soft(tokens.danger, 0.5).name()};"
" border-radius: 8px; }"
)
self.confirm_text.set_hint(
"warning",
f"Move '{state.pending_delete}' to Recently deleted? You can restore it any time.",
)
################################################################################
# Windows and flows
################################################################################
class ProfileLibraryDialog(QDialog):
"""Non-modal window hosting :class:`ProfileLibraryWidget`."""
def __init__(self, actions: ProfileActions, parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle("Profile library")
self.setModal(False)
self.resize(1040, 640)
self.setMinimumSize(820, 520)
self.controller = ProfileLibraryController(actions, self)
self.library = ProfileLibraryWidget(self.controller, self)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.library)
self.controller.name_requested.connect(self._ask_name)
self.controller.revert_requested.connect(self._ask_revert)
def _ask_name(self, mode: str, original: str) -> None:
actions = self.controller.actions
form = actions.name_form(mode, original)
preview = actions.live_preview() if mode == "save" else actions.preview(original)
dialog = ProfileNameDialog(
actions, mode, original, form["initial"], preview, self, form["notes"], form["pinned"]
)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
self.controller.apply_name(
mode, original, dialog.profile_name(), notes=dialog.notes(), pinned=dialog.pinned()
)
def _ask_revert(self, name: str) -> None:
if run_revert_flow(self.controller.actions, name, self):
self.controller.apply_revert(name)
def run_name_flow(
actions: ProfileActions, mode: NameMode, original: str = "", parent: QWidget | None = None
) -> str | None:
"""
Show the name dialog and run the save, rename or duplicate.
Returns:
str | None: The new name, or None when cancelled.
"""
form = actions.name_form(mode, original)
preview = actions.preview(original) if mode == "duplicate" else actions.live_preview()
dialog = ProfileNameDialog(
actions, mode, original, form["initial"], preview, parent, form["notes"], form["pinned"]
)
if dialog.exec() != QDialog.DialogCode.Accepted:
return None
actions.apply_name(mode, original, dialog.profile_name(), dialog.notes(), dialog.pinned())
return dialog.profile_name()
def run_revert_flow(actions: ProfileActions, name: str, parent: QWidget | None = None) -> bool:
"""Ask whether to revert *name*; returns True when confirmed (the caller reverts)."""
current = actions.live_preview(name) or actions.preview(name)
dialog = RevertProfileDialog(
name,
actions.saved_at(name),
current,
actions.saved_preview(name),
is_profile_read_only(name, actions.namespace),
parent,
)
return dialog.exec() == QDialog.DialogCode.Accepted
@@ -0,0 +1,35 @@
import QtQuick
import QtQuick.Layouts
import BecUi
// Icon and text in the colour of a tone: neutral, info, success, warning or error.
RowLayout {
id: root
property string tone: "neutral"
property string text: ""
readonly property color toneColor: tone === "error" ? theme.danger
: tone === "warning" ? theme.warning
: tone === "success" ? theme.success
: tone === "info" ? theme.primary
: theme.fgMuted
readonly property color textColor: tone === "neutral" ? theme.fgMuted
: tone === "warning" && !theme.dark ? Qt.darker(theme.warning, 1.6)
: toneColor
spacing: 6
Icon {
Layout.alignment: Qt.AlignTop
Layout.topMargin: 1
visible: root.tone !== "neutral"
size: 16
color: root.toneColor
name: root.tone === "error" ? "error" : root.tone === "warning" ? "warning"
: root.tone === "success" ? "check_circle" : "info"
}
Text {
Layout.fillWidth: true
text: root.text
color: root.textColor
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
@@ -0,0 +1,50 @@
import QtQuick
import BecUi
// Screenshot preview that keeps the aspect ratio, with a placeholder text.
Item {
id: root
property string source: ""
property string placeholder: "No preview yet"
readonly property bool hasImage: source !== "" && image.status === Image.Ready
Rectangle {
anchors.fill: parent
visible: !root.hasImage
radius: 8
color: Qt.rgba(theme.fg.r, theme.fg.g, theme.fg.b, 0.04)
border.color: theme.border
Column {
anchors.centerIn: parent
spacing: 6
Icon { anchors.horizontalCenter: parent.horizontalCenter; name: "dashboard"; size: 32; color: theme.fgSubtle }
Text {
anchors.horizontalCenter: parent.horizontalCenter
width: root.width - 16
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: root.placeholder
color: theme.fgSubtle
font.pixelSize: 12
}
}
}
Image {
id: image
anchors.fill: parent
source: root.source
cache: false
smooth: true
mipmap: true
fillMode: Image.PreserveAspectFit
}
Rectangle {
visible: root.hasImage
anchors.centerIn: parent
width: image.paintedWidth + 1
height: image.paintedHeight + 1
radius: 4
color: "transparent"
border.color: theme.border
}
}
@@ -0,0 +1,400 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
import BecUi
// Profile library: searchable list with sections on the left, the selected profile's
// preview, description, facts and actions on the right.
Rectangle {
id: root
required property var backend
readonly property var st: backend.state
readonly property var sel: st.selected || ({})
readonly property bool hasSelection: sel.key !== undefined
readonly property bool isTrash: sel.kind === "trash"
readonly property var banner: st.banner || ({})
color: theme.bg
function toneColor(tone) {
return tone === "error" ? theme.danger : tone === "warning" ? theme.warning
: tone === "success" ? theme.success : tone === "info" ? theme.primary : theme.fgMuted
}
function soft(c, a) { return Qt.rgba(c.r, c.g, c.b, a) }
Shortcut { sequence: "Ctrl+F"; onActivated: search.forceActiveFocus() }
Shortcut { sequence: "F2"; onActivated: root.backend.requestName("rename") }
Shortcut { sequence: "Ctrl+D"; onActivated: root.backend.requestName("duplicate") }
Shortcut { sequence: "Ctrl+S"; onActivated: root.backend.requestName("save") }
Shortcut { sequence: "Delete"; enabled: !notes.activeFocus && !search.activeFocus; onActivated: root.backend.requestDelete() }
ColumnLayout {
anchors.fill: parent
anchors.leftMargin: 16
anchors.rightMargin: 16
anchors.topMargin: 14
anchors.bottomMargin: 14
spacing: 10
// ---------------------------------------------------------------- header
RowLayout {
Layout.fillWidth: true
spacing: 10
ColumnLayout {
spacing: 0
Text { text: "Profiles"; color: theme.fg; font.pixelSize: 16; font.weight: Font.DemiBold }
Text {
text: root.st.total + (root.st.total === 1 ? " profile" : " profiles")
color: theme.fgMuted
font.pixelSize: 12
}
}
Item { Layout.fillWidth: true }
InputField {
id: search
Layout.preferredWidth: 260
placeholderText: "Search profiles (Ctrl+F)"
leftPadding: 30
text: root.st.query
onTextEdited: root.backend.setQuery(text)
Keys.onDownPressed: root.backend.moveSelection(1)
Keys.onUpPressed: root.backend.moveSelection(-1)
Keys.onReturnPressed: root.backend.openSelected(true)
Icon {
anchors.left: parent.left
anchors.leftMargin: 9
anchors.verticalCenter: parent.verticalCenter
name: "search"
size: 16
color: theme.fgSubtle
}
}
TextButton {
text: "Save current layout…"
variant: "primary"
iconName: "save"
onClicked: root.backend.requestName("save")
}
}
// ---------------------------------------------------------------- banner
Rectangle {
Layout.fillWidth: true
visible: root.banner.text !== undefined
implicitHeight: bannerRow.implicitHeight + 12
radius: 8
color: root.soft(root.toneColor(root.banner.tone), 0.14)
border.color: root.soft(root.toneColor(root.banner.tone), 0.45)
RowLayout {
id: bannerRow
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 6
spacing: 6
HintLine {
Layout.fillWidth: true
tone: root.banner.tone || "neutral"
text: root.banner.text || ""
}
TextButton {
visible: (root.banner.action || "") !== ""
text: root.banner.action || ""
variant: "ghost"
implicitHeight: 28
onClicked: root.backend.bannerAction()
}
IconButton { iconName: "close"; tip: "Dismiss"; iconSize: 16; onClicked: root.backend.dismissBanner() }
}
}
// ---------------------------------------------------------------- body
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 14
Rectangle {
Layout.preferredWidth: 330
Layout.fillHeight: true
radius: 10
color: theme.card
border.color: theme.border
ListView {
id: list
anchors.fill: parent
anchors.margins: 6
clip: true
model: root.backend.rows
spacing: 1
boundsBehavior: Flickable.StopAtBounds
currentIndex: -1
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
section.property: "section"
section.delegate: Text {
required property string section
width: ListView.view.width
topPadding: 10
bottomPadding: 4
leftPadding: 8
text: section.toUpperCase()
color: theme.fgSubtle
font.pixelSize: 11
font.weight: Font.DemiBold
font.letterSpacing: 0.5
}
delegate: Rectangle {
id: row
required property var model
width: ListView.view.width
height: 48
radius: 8
color: model.selected ? root.soft(theme.primary, 0.14)
: rowMouse.containsMouse ? theme.hover : "transparent"
border.color: model.selected ? theme.primary : "transparent"
MouseArea {
id: rowMouse
anchors.fill: parent
hoverEnabled: true
onClicked: root.backend.select(row.model.key)
onDoubleClicked: if (row.model.kind !== "trash") root.backend.openSelected(true)
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 6
spacing: 8
Icon {
name: row.model.kind === "trash" ? "delete" : row.model.kind === "bundled" ? "lock" : "dashboard"
size: 18
color: row.model.isCurrent ? theme.primary : theme.fgSubtle
}
ColumnLayout {
Layout.fillWidth: true
spacing: 1
Text {
Layout.fillWidth: true
text: row.model.name
color: row.model.kind === "trash" ? theme.fgMuted : theme.fg
font.pixelSize: 13
font.weight: row.model.isCurrent ? Font.DemiBold : Font.Medium
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: row.model.subtitle
color: theme.fgSubtle
font.pixelSize: 11
elide: Text.ElideRight
}
}
StatusPill {
visible: row.model.isCurrent || row.model.isOpen
text: row.model.isCurrent ? "This tab" : "Open"
tone: row.model.isCurrent ? theme.primary : theme.success
}
TextButton {
visible: row.model.kind === "trash"
text: "Restore"
variant: "ghost"
iconName: "restore_from_trash"
implicitHeight: 28
onClicked: root.backend.restore(row.model.token)
}
AbstractButton {
id: pin
visible: row.model.kind !== "trash"
implicitWidth: 30
implicitHeight: 30
hoverEnabled: true
onClicked: root.backend.togglePin(row.model.key)
background: Rectangle { radius: 6; color: pin.hovered ? theme.hover : "transparent" }
contentItem: Item {
Icon {
anchors.centerIn: parent
name: "star"
size: 16
filled: row.model.pinned
color: row.model.pinned ? theme.warning : theme.fgSubtle
}
}
ToolTip.visible: hovered
ToolTip.delay: 500
ToolTip.text: row.model.pinned ? "Shown in the toolbar list. Click to hide."
: "Not in the toolbar list. Click to show."
}
}
}
}
Text {
anchors.centerIn: parent
width: parent.width - 32
visible: list.count === 0
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: root.st.emptyText
color: theme.fgMuted
font.pixelSize: 12
}
}
// ------------------------------------------------------------ details
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
visible: root.hasSelection
clip: true
radius: 10
color: theme.card
border.color: theme.border
ColumnLayout {
anchors.fill: parent
anchors.leftMargin: 16
anchors.rightMargin: 16
anchors.topMargin: 16
anchors.bottomMargin: 14
spacing: 10
PreviewFrame {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 220
source: root.isTrash ? "" : root.backend.previewUrl
placeholder: root.isTrash ? "Deleted profile. Restore it to open it." : "No preview yet"
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: root.sel.name || ""
color: theme.fg
font.pixelSize: 16
font.weight: Font.DemiBold
}
StatusPill {
visible: !root.isTrash && (root.sel.status || "") !== ""
text: root.sel.status || ""
tone: root.sel.isCurrent ? theme.primary : theme.success
}
Item { Layout.fillWidth: true }
TextButton {
visible: !root.isTrash
enabled: root.sel.canDelete === true
variant: "ghost"
iconName: "delete"
text: "Delete…"
tip: root.sel.deleteReason || "Move to Recently deleted"
onClicked: root.backend.requestDelete()
}
}
InputField {
id: notes
Layout.fillWidth: true
visible: !root.isTrash && !root.sel.readOnly
placeholderText: "Add a description…"
text: root.sel.notes || ""
onEditingFinished: root.backend.saveNotes(text)
}
HintLine {
Layout.fillWidth: true
visible: root.isTrash || root.sel.readOnly === true
tone: root.isTrash ? "neutral" : "info"
text: root.isTrash ? (root.sel.status || "")
: "Read-only profile from " + (root.sel.source || "BEC") + ". Your changes are kept as you work; "
+ "Revert brings back the original. Duplicate it to keep your own version."
}
GridLayout {
Layout.fillWidth: true
columns: 2
columnSpacing: 18
rowSpacing: 4
Repeater {
model: root.sel.facts || []
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
Layout.preferredWidth: 1
spacing: 12
Text { text: modelData.label; color: theme.fgMuted; font.pixelSize: 12; Layout.preferredWidth: 56 }
Text { text: modelData.value; color: theme.fg; font.pixelSize: 13; Layout.fillWidth: true; elide: Text.ElideRight }
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
TextButton {
visible: !root.isTrash && !root.sel.isCurrent
variant: "primary"
iconName: "tab"
text: root.st.hasTabs ? (root.sel.isOpen ? "Go to tab" : "Open in new tab") : "Open"
onClicked: root.backend.openSelected(true)
}
TextButton {
visible: !root.isTrash && root.st.hasTabs && !root.sel.isOpen
iconName: "open_in_browser"
text: "Open here"
onClicked: root.backend.openSelected(false)
}
TextButton {
visible: root.isTrash
variant: "primary"
iconName: "restore_from_trash"
text: "Restore"
onClicked: root.backend.restore("")
}
TextButton {
visible: !root.isTrash
iconName: "content_copy"
text: "Duplicate…"
onClicked: root.backend.requestName("duplicate")
}
TextButton {
visible: !root.isTrash && !root.sel.readOnly
iconName: "edit"
text: "Rename…"
onClicked: root.backend.requestName("rename")
}
TextButton {
visible: !root.isTrash
enabled: root.sel.canRevert === true
iconName: "history"
text: "Revert…"
tip: enabled ? "Go back to the saved layout" : "Nothing saved to go back to"
onClicked: root.backend.requestRevert()
}
Item { Layout.fillWidth: true }
}
Rectangle {
Layout.fillWidth: true
visible: root.st.pendingDelete !== ""
implicitHeight: confirmRow.implicitHeight + 16
radius: 8
color: root.soft(theme.danger, 0.12)
border.color: root.soft(theme.danger, 0.5)
RowLayout {
id: confirmRow
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 8
spacing: 8
HintLine {
Layout.fillWidth: true
tone: "warning"
text: "Move '" + root.st.pendingDelete + "' to Recently deleted? You can restore it any time."
}
TextButton { text: "Cancel"; onClicked: root.backend.cancelDelete() }
TextButton {
text: "Delete"
variant: "danger"
iconName: "delete"
onClicked: root.backend.confirmDelete()
}
}
}
}
}
}
}
}
@@ -0,0 +1,124 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
import BecUi
// Save, rename and duplicate a profile. The name is checked while typing; conflicts are
// explained under the field instead of in follow-up message boxes.
Rectangle {
id: root
required property var backend
readonly property var check: backend.check
color: theme.card
Keys.onEscapePressed: backend.cancel()
ColumnLayout {
anchors.fill: parent
anchors.leftMargin: 20
anchors.rightMargin: 20
anchors.topMargin: 18
anchors.bottomMargin: 16
spacing: 14
Text {
text: root.backend.heading
color: theme.fg
font.pixelSize: 16
font.weight: Font.DemiBold
Layout.fillWidth: true
elide: Text.ElideRight
}
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 16
PreviewFrame {
Layout.alignment: Qt.AlignTop
Layout.preferredWidth: 220
Layout.preferredHeight: 140
source: root.backend.previewUrl
placeholder: "Nothing to preview"
}
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
spacing: 6
Text { text: "Name"; color: theme.fg; font.pixelSize: 13; font.weight: Font.DemiBold }
InputField {
id: nameField
Layout.fillWidth: true
text: root.backend.initialName
placeholderText: "e.g. alignment, overview, tomo_scan"
invalid: root.check.tone === "error"
focus: true
onTextChanged: root.backend.setName(text)
onAccepted: root.backend.confirm()
Component.onCompleted: { selectAll(); forceActiveFocus() }
}
HintLine {
Layout.fillWidth: true
visible: root.check.message !== ""
tone: root.check.tone
text: root.check.message
}
TextButton {
visible: root.check.suggestion !== ""
variant: "ghost"
iconName: "auto_fix_high"
implicitHeight: 26
text: "Use '" + root.check.suggestion + "'"
onClicked: { nameField.text = root.check.suggestion; nameField.forceActiveFocus() }
}
ColumnLayout {
visible: root.backend.mode === "save"
Layout.fillWidth: true
Layout.topMargin: 6
spacing: 6
Text { text: "Description"; color: theme.fg; font.pixelSize: 13; font.weight: Font.DemiBold }
InputField {
Layout.fillWidth: true
text: root.backend.initialNotes
placeholderText: "Optional, shown in the profile library"
onTextChanged: root.backend.setNotes(text)
onAccepted: root.backend.confirm()
}
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 6
ColumnLayout {
Layout.fillWidth: true
spacing: 0
Text { text: "Show in toolbar list"; color: theme.fg; font.pixelSize: 13; font.weight: Font.DemiBold }
Text { text: "Quick access from the profile drop-down."; color: theme.fgMuted; font.pixelSize: 12 }
}
SwitchField {
checked: root.backend.initialPinned
text: checked ? "On" : "Off"
onToggled: root.backend.setPinned(checked)
}
}
}
Item { Layout.fillHeight: true }
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Item { Layout.fillWidth: true }
TextButton { text: "Cancel"; variant: "neutral"; onClicked: root.backend.cancel() }
TextButton {
text: root.check.action
variant: root.check.replaces ? "danger" : "primary"
enabled: root.check.ok
onClicked: root.backend.confirm()
}
}
}
}
@@ -0,0 +1,101 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
import BecUi
// Confirm reverting a profile to its saved layout; current and saved layout side by side.
Rectangle {
id: root
required property var backend
color: theme.card
Keys.onEscapePressed: backend.cancel()
ColumnLayout {
anchors.fill: parent
anchors.leftMargin: 20
anchors.rightMargin: 20
anchors.topMargin: 18
anchors.bottomMargin: 16
spacing: 12
Text {
Layout.fillWidth: true
text: "Revert '" + root.backend.name + "' to its saved layout?"
color: theme.fg
font.pixelSize: 16
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: root.backend.text
color: theme.fgMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 12
Repeater {
model: [
{ caption: "NOW", url: root.backend.nowUrl, sub: "Current layout" },
{ caption: "AFTER REVERT", url: root.backend.savedUrl, sub: root.backend.savedCaption }
]
delegate: RowLayout {
required property var modelData
required property int index
Layout.fillWidth: true
Layout.preferredWidth: 1
Layout.fillHeight: true
spacing: 12
Icon {
visible: index === 1
name: "arrow_forward"
size: 20
color: theme.fgSubtle
}
ColumnLayout {
Layout.fillWidth: true
Layout.preferredWidth: 1
Layout.fillHeight: true
spacing: 6
Text {
text: modelData.caption
color: theme.fgSubtle
font.pixelSize: 11
font.weight: Font.DemiBold
font.letterSpacing: 0.5
}
PreviewFrame {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 150
source: modelData.url
placeholder: "No screenshot"
}
Text { text: modelData.sub; color: theme.fgMuted; font.pixelSize: 12 }
}
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Item { Layout.fillWidth: true }
TextButton {
text: "Keep current layout"
variant: "neutral"
focus: true
onClicked: root.backend.cancel()
Keys.onReturnPressed: root.backend.cancel()
}
TextButton {
text: "Revert"
variant: "danger"
iconName: "history"
onClicked: root.backend.confirm()
}
}
}
}
@@ -123,27 +123,43 @@ class ProfileComboBox(QComboBox):
self.blockSignals(was_blocked)
def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle:
def workspace_bundle(
components: ToolbarComponents, enable_tools: bool = True, reworked: bool = False
) -> ToolbarBundle:
"""
Creates a workspace toolbar bundle for BECDockArea.
Args:
components (ToolbarComponents): The components to be added to the bundle.
enable_tools(bool): If True, show the workspace management tools; otherwise, only show the profile combo.
reworked(bool): Use the icons and labels of the reworked profile windows.
Returns:
ToolbarBundle: The workspace toolbar bundle.
"""
# Workspace combo
labels = {
"save": ("save", "Save Current Workspace"),
"restore": ("undo", "Restore Baseline Profile"),
"manage": ("manage_accounts", "Manage"),
}
if reworked:
labels = {
"save": ("save", "Save profile…"),
"restore": ("history", "Revert to saved layout…"),
"manage": ("folder_open", "Profile library"),
}
combo = ProfileComboBox(parent=components.toolbar)
combo.setVisible(enable_tools)
if reworked:
combo.setToolTip("Profiles shown in the toolbar list. Open the library for all profiles.")
components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False))
components.add_safe(
"save_workspace",
MaterialIconAction(
icon_name="save",
tooltip="Save Current Workspace",
icon_name=labels["save"][0],
tooltip=labels["save"][1],
checkable=False,
parent=components.toolbar,
),
@@ -153,8 +169,8 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
components.add_safe(
"reset_baseline_workspace",
MaterialIconAction(
icon_name="undo",
tooltip="Restore Baseline Profile",
icon_name=labels["restore"][0],
tooltip=labels["restore"][1],
checkable=False,
parent=components.toolbar,
),
@@ -164,7 +180,10 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
components.add_safe(
"manage_workspaces",
MaterialIconAction(
icon_name="manage_accounts", tooltip="Manage", checkable=True, parent=components.toolbar
icon_name=labels["manage"][0],
tooltip=labels["manage"][1],
checkable=True,
parent=components.toolbar,
),
)
components.get_action("manage_workspaces").action.setVisible(enable_tools)
@@ -0,0 +1,539 @@
"""Tabbed workspaces: several dock areas side by side, one per tab, each showing a profile."""
from __future__ import annotations
from typing import Any
from bec_lib import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import QPoint, Qt, Signal
from qtpy.QtWidgets import (
QInputDialog,
QMenu,
QMessageBox,
QTabWidget,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea, StartupProfile
from bec_widgets.widgets.containers.dock_area.profile_utils import (
get_open_workspaces,
has_saved_baseline,
is_profile_read_only,
list_profiles,
profile_origin,
set_open_workspaces,
)
from bec_widgets.widgets.containers.dock_area.profile_ux import (
POLICY,
ask_profile_name,
profile_ui_mode,
)
logger = bec_logger.logger
UNTITLED_WORKSPACE = "Untitled"
class WorkspacePage(QWidget):
"""
One tab of :class:`WorkspaceTabs`. The dock area is created on first activation, so
restored tabs that are never opened cost nothing at startup.
Args:
parent(QWidget | None): Parent widget.
profile(str | None): Profile the tab shows, or None for an unsaved empty workspace.
startup_profile(StartupProfile): Startup profile passed to the dock area; defaults
to ``profile``.
"""
def __init__(
self,
parent: QWidget | None = None,
profile: str | None = None,
startup_profile: StartupProfile = None,
):
super().__init__(parent)
self._pending_profile = profile
self._startup_profile = startup_profile if startup_profile is not None else profile
self.dock_area: BECDockArea | None = None
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
@property
def profile(self) -> str | None:
"""Profile shown in this tab, or None for an unsaved workspace."""
if self.dock_area is None:
return self._pending_profile
return self.dock_area._current_profile_name # pylint: disable=protected-access
@property
def is_materialized(self) -> bool:
"""Whether the dock area of this tab has been created."""
return self.dock_area is not None
def materialize(self, **dock_area_kwargs: Any) -> BECDockArea:
"""
Create the dock area of this tab if it does not exist yet.
Args:
**dock_area_kwargs: Keyword arguments for :class:`BECDockArea`.
Returns:
BECDockArea: The dock area of this tab.
"""
if self.dock_area is None:
self.dock_area = BECDockArea(
self, startup_profile=self._startup_profile, **dock_area_kwargs
)
self.layout().addWidget(self.dock_area)
return self.dock_area
class WorkspaceTabs(QTabWidget):
"""
Tab widget hosting one :class:`BECDockArea` per workspace.
Each tab shows one workspace profile. Tabs can be created, renamed, switched, reordered
and closed. Closing a tab autosaves its profile and keeps it on disk. The set of open
tabs is stored with the profiles and restored on the next start.
Args:
parent(QWidget | None): Parent widget.
profile_namespace(str): Profile namespace shared by all tabs.
restore(bool): Restore the tabs that were open last time.
**dock_area_kwargs: Extra keyword arguments for every :class:`BECDockArea`.
"""
current_dock_area_changed = Signal(object)
def __init__(
self,
parent: QWidget | None = None,
*,
profile_namespace: str = "bec",
restore: bool = True,
**dock_area_kwargs: Any,
):
super().__init__(parent)
self.setObjectName("WorkspaceTabs")
self.profile_namespace = profile_namespace
self._dock_area_kwargs = {
"profile_namespace": profile_namespace,
"auto_profile_namespace": False,
"object_name": "DockArea",
**dock_area_kwargs,
}
self._closing = False
self._restoring = False
self.setDocumentMode(True)
self.setMovable(True)
self.setTabsClosable(True)
self.setElideMode(Qt.TextElideMode.ElideRight)
# Size tabs to their titles; the app theme stretches tab bars to full width by default.
self.tabBar().setObjectName("WorkspaceTabBar")
self.setStyleSheet("QTabBar#WorkspaceTabBar { qproperty-expanding: false; }")
self.tabCloseRequested.connect(self.close_workspace)
self.currentChanged.connect(self._on_current_changed)
self.tabBar().tabMoved.connect(lambda *_: self._persist())
self.tabBar().tabBarDoubleClicked.connect(self._on_tab_double_clicked)
self.tabBar().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.tabBar().customContextMenuRequested.connect(self._show_tab_menu)
self.new_workspace_button = QToolButton(self)
self.new_workspace_button.setObjectName("NewWorkspaceButton")
self.new_workspace_button.setIcon(material_icon("add"))
self.new_workspace_button.setAutoRaise(True)
self.new_workspace_button.setToolTip("New workspace (click) or open a saved one (arrow)")
self.new_workspace_button.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
self._open_menu = QMenu(self.new_workspace_button)
self._open_menu.aboutToShow.connect(self._populate_open_menu)
self.new_workspace_button.setMenu(self._open_menu)
self.new_workspace_button.clicked.connect(self.new_workspace)
self.setCornerWidget(self.new_workspace_button, Qt.Corner.TopRightCorner)
if restore:
self._restore_tabs()
################################################################################
# Public API
################################################################################
def pages(self) -> list[WorkspacePage]:
"""Return the workspace pages in tab order."""
return [self.widget(i) for i in range(self.count())]
def dock_areas(self) -> list[BECDockArea]:
"""Return the dock areas that have been created, in tab order."""
return [page.dock_area for page in self.pages() if page.dock_area is not None]
def current_dock_area(self) -> BECDockArea | None:
"""Return the dock area of the current tab, creating it if needed."""
page = self.currentWidget()
if page is None:
return None
return self._materialize(page)
def workspace_names(self) -> list[str | None]:
"""Return the profile of every tab in order; None marks an unsaved workspace."""
return [page.profile for page in self.pages()]
def find_workspace(self, profile: str) -> int:
"""
Return the tab index showing *profile*, or -1 if it is not open.
Args:
profile(str): The profile name.
"""
for index, page in enumerate(self.pages()):
if page.profile == profile:
return index
return -1
def add_workspace(
self,
profile: str | None = None,
*,
activate: bool = True,
startup_profile: StartupProfile = None,
) -> WorkspacePage:
"""
Add a workspace tab.
Args:
profile(str | None): Profile to show, or None for a new empty workspace.
activate(bool): Make the new tab current.
startup_profile(StartupProfile): Override the dock area startup profile,
e.g. ``"restore"`` to reopen the last used profile.
Returns:
WorkspacePage: The new tab page.
"""
page = WorkspacePage(self, profile=profile, startup_profile=startup_profile)
index = self.addTab(page, self._tab_title(profile))
self.setTabToolTip(index, self._tab_tooltip(profile))
if activate:
self.setCurrentIndex(index)
self._materialize(page)
self._persist()
return page
@SafeSlot()
def new_workspace(self) -> WorkspacePage:
"""Open a new empty workspace in a new tab."""
return self.add_workspace(None)
@SafeSlot(str)
def open_workspace(self, profile: str) -> WorkspacePage:
"""
Show *profile* in a tab, switching to its tab when it is already open.
Args:
profile(str): The profile name.
Returns:
WorkspacePage: The tab page showing the profile.
"""
index = self.find_workspace(profile)
if index >= 0:
self.setCurrentIndex(index)
return self.widget(index)
return self.add_workspace(profile)
@SafeSlot(int)
def close_workspace(self, index: int, confirm: bool = True) -> bool:
"""
Close the tab at *index*. Its profile is autosaved and kept on disk.
Closing the last tab leaves a new empty workspace behind.
Args:
index(int): The tab index.
confirm(bool): Ask before discarding an unsaved workspace that has widgets.
Returns:
bool: True if the tab was closed.
"""
page = self.widget(index)
if page is None:
return False
dock_area = page.dock_area
if (
confirm
and dock_area is not None
and page.profile is None
and dock_area.dock_list()
and not self._confirm_close_unsaved()
):
return False
self.removeTab(index)
if dock_area is not None:
dock_area.close()
dock_area.deleteLater()
page.deleteLater()
if self.count() == 0 and not self._closing:
self.new_workspace()
self._persist()
return True
@SafeSlot()
@SafeSlot(int)
def rename_workspace(
self, index: int | None = None, name: str | None = None, confirm: bool = True
) -> bool:
"""
Rename the workspace of the tab at *index* (the current tab by default).
The layout is saved under the new profile name. The previous profile stays in the
profile library untouched, so renaming never removes anything from disk.
Args:
index(int | None): The tab index; defaults to the current tab.
name(str | None): The new name; when None, the user is asked for one.
confirm(bool): Ask before replacing an existing profile. The reworked rename
dialog has already asked, so it passes False.
Returns:
bool: True if the workspace now has the new name.
"""
if index is None or index < 0:
index = self.currentIndex()
page = self.widget(index)
if page is None:
return False
current = page.profile
if name is None and profile_ui_mode() != "legacy":
dock_area = self._materialize(page)
if index != self.currentIndex():
self.setCurrentIndex(index)
result = ask_profile_name(dock_area, "rename", current or "", parent=self)
return result is not None and page.profile == result
if name is None:
name, ok = QInputDialog.getText(
self, "Rename Workspace", "Workspace name:", text=current or ""
)
if not ok:
return False
name = (name or "").strip()
if not name or name == current:
return False
if confirm and not self._may_rename_to(name):
return False
if not confirm and self.find_workspace(name) >= 0:
return False
dock_area = self._materialize(page)
dock_area.save_profile(name, show_dialog=False)
self._sync_tab(page)
self._persist()
return page.profile == name
def cleanup(self) -> None:
"""Persist the open tabs and close every dock area (each autosaves its profile)."""
self._persist()
self._closing = True
for page in self.pages():
if page.dock_area is not None:
page.dock_area.close()
page.dock_area.deleteLater()
################################################################################
# Internals
################################################################################
def _restore_tabs(self) -> None:
names, current = get_open_workspaces(namespace=self.profile_namespace)
known = set(list_profiles(self.profile_namespace)) if names else set()
names = [name for name in names if name in known]
self._restoring = True
try:
if names:
for name in names:
self.add_workspace(name, activate=False)
target = self.find_workspace(current) if current else -1
self.setCurrentIndex(max(target, 0))
else:
# Nothing stored yet: keep the single-workspace behaviour of reopening the
# last used profile.
self.add_workspace(None, activate=False, startup_profile="restore")
finally:
self._restoring = False
self._materialize(self.currentWidget())
self._persist()
def _materialize(self, page: WorkspacePage) -> BECDockArea:
if page.dock_area is not None:
return page.dock_area
dock_area = page.materialize(**self._dock_area_kwargs)
dock_area.profile_in_use_elsewhere = lambda name, p=page: self._is_open_elsewhere(name, p)
dock_area.profile_changed.connect(lambda _name, p=page: self._on_profile_changed(p))
dock_area.profile_redirected.connect(self.open_workspace)
dock_area.workspace_host = self
self._sync_tab(page)
return dock_area
def _may_rename_to(self, name: str) -> bool:
if self.find_workspace(name) >= 0:
QMessageBox.information(
self, "Rename Workspace", f"Workspace '{name}' is already open in another tab."
)
return False
if is_profile_read_only(name, namespace=self.profile_namespace):
QMessageBox.information(
self,
"Rename Workspace",
f"'{name}' is a read-only bundled profile. Choose another name.",
)
return False
if profile_origin(name, namespace=self.profile_namespace) != "unknown":
reply = QMessageBox.question(
self,
"Rename Workspace",
f"A saved workspace named '{name}' already exists.\n\n"
"Replace it with the layout of this tab?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return False
return True
def _is_open_elsewhere(self, name: str, page: WorkspacePage) -> bool:
if not POLICY.one_tab_per_profile:
return False
return any(other is not page and other.profile == name for other in self.pages())
def _on_profile_changed(self, page: WorkspacePage) -> None:
self._sync_tab(page)
self._persist()
def _sync_tab(self, page: WorkspacePage) -> None:
index = self.indexOf(page)
if index < 0:
return
self.setTabText(index, self._tab_title(page.profile))
self.setTabToolTip(index, self._tab_tooltip(page.profile))
@staticmethod
def _tab_title(profile: str | None) -> str:
return profile or UNTITLED_WORKSPACE
@staticmethod
def _tab_tooltip(profile: str | None) -> str:
if profile is None:
return "Unsaved workspace. Rename or save it to keep it."
return f"Workspace profile '{profile}'"
def _persist(self) -> None:
if self._closing or self._restoring:
return
names = [name for name in self.workspace_names() if name]
current_page = self.currentWidget()
current = current_page.profile if current_page is not None else None
set_open_workspaces(names, current, namespace=self.profile_namespace)
def _on_current_changed(self, index: int) -> None:
if self._closing or self._restoring or index < 0:
return
dock_area = self._materialize(self.widget(index))
self._persist()
self.current_dock_area_changed.emit(dock_area)
def _on_tab_double_clicked(self, index: int) -> None:
if index >= 0:
self.rename_workspace(index)
def _confirm_close_unsaved(self) -> bool:
reply = QMessageBox.question(
self,
"Close Workspace",
"This workspace has not been saved. Close it and its widgets?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
return reply == QMessageBox.StandardButton.Yes
def _populate_open_menu(self) -> None:
menu = self._open_menu
menu.clear()
menu.addAction(material_icon("add"), "New Workspace", self.new_workspace)
profiles = list_profiles(self.profile_namespace)
if profiles:
menu.addSection("Open Saved Workspace")
open_names = set(self.workspace_names())
for name in profiles:
action = menu.addAction(name, lambda n=name: self.open_workspace(n))
if name in open_names:
action.setCheckable(True)
action.setChecked(True)
action.setToolTip("Already open; switches to its tab")
if profile_ui_mode() != "legacy":
menu.addSeparator()
menu.addAction(material_icon("folder_open"), "Profile library…", self.show_library)
def _show_tab_menu(self, pos: QPoint) -> None:
index = self.tabBar().tabAt(pos)
if index < 0:
return
menu = QMenu(self)
if profile_ui_mode() == "legacy":
menu.addAction("Rename…", lambda: self.rename_workspace(index))
menu.addAction("Close", lambda: self.close_workspace(index))
menu.addSeparator()
menu.addAction("New Workspace", self.new_workspace)
menu.exec(self.tabBar().mapToGlobal(pos))
return
page = self.widget(index)
profile = page.profile
menu.addAction(material_icon("edit"), "Rename…", lambda: self.rename_workspace(index))
save = menu.addAction(
material_icon("save"), "Save as…", lambda: self._tab_flow(index, "save")
)
save.setToolTip("Save this layout under a new or existing name")
duplicate = menu.addAction(
material_icon("content_copy"),
"Duplicate…",
lambda: self._tab_flow(index, "duplicate", profile),
)
duplicate.setEnabled(profile is not None)
revert = menu.addAction(
material_icon("history"), "Revert to saved…", lambda: self._tab_revert(index)
)
revert.setEnabled(
profile is not None and has_saved_baseline(profile, self.profile_namespace)
)
menu.addSeparator()
menu.addAction(material_icon("folder_open"), "Profile library…", self.show_library)
menu.addAction(material_icon("add"), "New Workspace", self.new_workspace)
menu.addSeparator()
menu.addAction(material_icon("close"), "Close tab", lambda: self.close_workspace(index))
menu.exec(self.tabBar().mapToGlobal(pos))
def _tab_flow(self, index: int, mode: str, original: str | None = None) -> None:
if index != self.currentIndex():
self.setCurrentIndex(index)
dock_area = self._materialize(self.widget(index))
ask_profile_name(dock_area, mode, original or "", parent=self)
def _tab_revert(self, index: int) -> None:
if index != self.currentIndex():
self.setCurrentIndex(index)
dock_area = self._materialize(self.widget(index))
dock_area.restore_baseline_profile(show_dialog=True)
@SafeSlot()
def show_library(self) -> None:
"""Open the profile library of the current workspace."""
dock_area = self.current_dock_area()
if dock_area is not None:
dock_area.show_workspace_manager()
+14 -1
View File
@@ -151,7 +151,8 @@ def test_view_switch_method_switches_to_target(app_with_spies, qtbot):
def test_view_content_widget_is_hidden_from_namespace(app_with_spies):
app, _, _, _ = app_with_spies
assert app.dock_area.content is app.dock_area.dock_area
assert app.dock_area.content is app.dock_area.workspaces
assert app.dock_area.dock_area is app.dock_area.workspaces.current_dock_area()
def test_developer_view_is_experimental_by_default(monkeypatch, tmp_path, qtbot, mocked_client):
@@ -305,3 +306,15 @@ def test_guided_tour_can_start_and_stop(app_with_spies, qtbot):
# Check that tour is stopped
assert not app.guided_tour._active
def test_dock_area_view_rpc_targets_current_workspace_tab(app_with_spies):
app, _, _, _ = app_with_spies
view = app.dock_area
first = view.dock_area
view.workspaces.new_workspace()
second = getattr(view, type(view).RPC_CONTENT_ATTR)
assert second is view.workspaces.current_dock_area()
assert second is not first
view.workspaces.setCurrentIndex(0)
assert getattr(view, type(view).RPC_CONTENT_ATTR) is first
+460
View File
@@ -0,0 +1,460 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, redefined-outer-name
# pylint: disable=protected-access
import shutil
import pytest
from qtpy.QtCore import QDateTime, Qt
from qtpy.QtQuickWidgets import QQuickWidget
from qtpy.QtWidgets import QDialog
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.containers.dock_area import profile_utils
from bec_widgets.widgets.containers.dock_area.profile_utils import (
copy_profile,
get_profile_info,
is_quick_select,
list_profiles,
list_trashed_profiles,
profile_origin,
restore_trashed_profile,
set_profile_notes,
trash_profile,
)
from bec_widgets.widgets.containers.dock_area.profile_ux import (
POLICY,
ask_profile_name,
create_profile_library,
profile_qml,
profile_qwidget,
profile_ui_mode,
)
from bec_widgets.widgets.containers.dock_area.profile_ux.common import (
SECTION_BUNDLED,
SECTION_MINE,
SECTION_TRASH,
ProfileActions,
ProfileLibraryController,
check_profile_name,
relative_time,
unique_name,
)
from bec_widgets.widgets.containers.dock_area.workspace_tabs import WorkspaceTabs
NAMESPACE = "ux_test"
WIDGET = "Waveform"
@pytest.fixture(autouse=True)
def isolate_profile_storage(tmp_path, monkeypatch):
monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(tmp_path / "profiles_root"))
bundled = tmp_path / "bundled"
bundled.mkdir()
monkeypatch.setattr(profile_utils, "module_profiles_dir", lambda: str(bundled))
yield bundled
@pytest.fixture(autouse=True)
def default_policy():
keeps, one_tab = POLICY.rename_keeps_old_profile, POLICY.one_tab_per_profile
yield
POLICY.rename_keeps_old_profile, POLICY.one_tab_per_profile = keeps, one_tab
@pytest.fixture
def make_tabs(qtbot, mocked_client, monkeypatch):
created = []
def _make(ui="qwidget"):
monkeypatch.setenv("BEC_PROFILE_UI", ui)
tabs = WorkspaceTabs(profile_namespace=NAMESPACE, client=mocked_client)
qtbot.addWidget(tabs)
tabs.show()
qtbot.waitExposed(tabs)
created.append(tabs)
return tabs
yield _make
for tabs in created:
tabs.cleanup()
def _save(tabs, name, **kwargs):
tabs.current_dock_area().save_profile(name, show_dialog=False, **kwargs)
def _bundle(tabs, bundled, name):
"""Turn the current layout into a read-only bundled profile called *name*."""
_save(tabs, "_src")
(bundled / f"{name}.ini").write_bytes(
open(profile_utils.runtime_profile_path("_src", NAMESPACE), "rb").read()
)
tabs.current_dock_area()._current_profile_name = None
trash_profile("_src", NAMESPACE)
shutil.rmtree(profile_utils._trash_root(NAMESPACE))
list_profiles(NAMESPACE)
def _accept_with(monkeypatch, cls, name, extra=None):
"""Make *cls*.exec accept with *name* typed into the field."""
def fake_exec(dialog):
if hasattr(dialog, "name_edit"):
dialog.name_edit.setText(name)
else:
dialog.backend.setName(name)
if extra:
extra(dialog)
return QDialog.DialogCode.Accepted if dialog_ok(dialog) else QDialog.DialogCode.Rejected
def dialog_ok(dialog):
return dialog.check.ok if hasattr(dialog, "name_edit") else dialog.backend.check["ok"]
monkeypatch.setattr(cls, "exec", fake_exec)
################################################################################
# Rules
################################################################################
def test_ui_mode_defaults_to_legacy(monkeypatch):
monkeypatch.delenv("BEC_PROFILE_UI", raising=False)
assert profile_ui_mode() == "legacy"
monkeypatch.setenv("BEC_PROFILE_UI", "QML")
assert profile_ui_mode() == "qml"
monkeypatch.setenv("BEC_PROFILE_UI", "nonsense")
assert profile_ui_mode() == "legacy"
def test_relative_time():
now = QDateTime.fromString("2026-10-09T12:00:00Z", Qt.DateFormat.ISODate)
assert relative_time("2026-10-09T11:59:30Z", now) == "just now"
assert relative_time("2026-10-09T11:15:00Z", now) == "45 min ago"
assert relative_time("2026-10-09T09:00:00Z", now) == "3 h ago"
assert relative_time("2026-10-08T09:00:00Z", now) == "yesterday"
assert relative_time("2026-10-04T12:00:00Z", now) == "5 days ago"
assert relative_time("garbage", now) == ""
def test_unique_name():
taken = {"a_copy", "a_copy_2"}
assert unique_name("a", taken.__contains__) == "a_copy_3"
assert unique_name("b", taken.__contains__, "custom") == "b_custom"
def test_name_check_basic_rules(make_tabs):
tabs = make_tabs()
_save(tabs, "alignment")
check = lambda text, mode="save", **kw: check_profile_name( # noqa: E731
text, mode, namespace=NAMESPACE, **kw
)
assert not check(" ").ok
bad = check("a/b")
assert not bad.ok and bad.tone == "error" and "/" in bad.message
assert not check(".hidden").ok
assert not check("x" * 65).ok
assert not check("alignment", "rename", original="alignment").ok
update = check("alignment", current="alignment")
assert update.ok and update.action == "Save" and not update.replaces
replace = check("alignment", current="other")
assert replace.ok and replace.replaces and replace.action == "Replace"
fresh = check("brand_new")
assert fresh.ok and fresh.tone == "neutral"
def test_name_check_read_only_suggests_free_name(make_tabs, isolate_profile_storage):
tabs = make_tabs()
_bundle(tabs, isolate_profile_storage, "overview")
result = check_profile_name("overview", "save", namespace=NAMESPACE)
assert not result.ok
assert result.suggestion == "overview_custom"
def test_name_check_open_elsewhere_depends_on_policy(make_tabs):
tabs = make_tabs()
_save(tabs, "first")
tabs.new_workspace()
actions = ProfileActions(tabs.current_dock_area())
blocked = actions.check("first", "save")
assert not blocked.ok and "another tab" in blocked.message
POLICY.one_tab_per_profile = False
assert actions.check("first", "save").ok
def test_rename_hint_follows_policy(make_tabs):
tabs = make_tabs()
_save(tabs, "old")
keep = check_profile_name("new", "rename", namespace=NAMESPACE, original="old")
assert "stays in the library" in keep.message
POLICY.rename_keeps_old_profile = False
move = check_profile_name("new", "rename", namespace=NAMESPACE, original="old")
assert "Recently deleted" in move.message
################################################################################
# Storage helpers
################################################################################
def test_trash_and_restore_round_trip(make_tabs):
tabs = make_tabs()
_save(tabs, "keep_me")
tabs.current_dock_area()._current_profile_name = None
entry = trash_profile("keep_me", NAMESPACE)
assert entry is not None
assert "keep_me" not in list_profiles(NAMESPACE)
assert [t.name for t in list_trashed_profiles(NAMESPACE)] == ["keep_me"]
assert restore_trashed_profile(entry.token, NAMESPACE) == "keep_me"
assert "keep_me" in list_profiles(NAMESPACE)
assert list_trashed_profiles(NAMESPACE) == []
def test_restore_refuses_when_name_is_taken_again(make_tabs):
tabs = make_tabs()
_save(tabs, "dup")
entry = trash_profile("dup", NAMESPACE)
_save(tabs, "dup")
with pytest.raises(FileExistsError):
restore_trashed_profile(entry.token, NAMESPACE)
def test_read_only_profiles_cannot_be_trashed(make_tabs, isolate_profile_storage):
tabs = make_tabs()
_bundle(tabs, isolate_profile_storage, "bundled")
assert trash_profile("bundled", NAMESPACE) is None
assert "bundled" in list_profiles(NAMESPACE)
def test_copy_profile_and_notes(make_tabs):
tabs = make_tabs()
_save(tabs, "src")
set_profile_notes("src", "my notes", NAMESPACE)
copy_profile("src", "dst", NAMESPACE)
assert profile_origin("dst", NAMESPACE) == "settings"
assert get_profile_info("dst", NAMESPACE).notes == "my notes"
with pytest.raises(FileExistsError):
copy_profile("src", "dst", NAMESPACE)
set_profile_notes("src", "", NAMESPACE)
assert get_profile_info("src", NAMESPACE).notes == ""
################################################################################
# Library controller
################################################################################
def test_library_sections_and_flags(make_tabs, isolate_profile_storage):
tabs = make_tabs()
_bundle(tabs, isolate_profile_storage, "shipped")
_save(tabs, "mine")
_save(tabs, "gone")
tabs.current_dock_area()._current_profile_name = None
trash_profile("gone", NAMESPACE)
_save(tabs, "mine")
controller = ProfileLibraryController(ProfileActions(tabs.current_dock_area()))
sections = {row["name"]: row["section"] for row in controller.state.rows}
assert sections == {"mine": SECTION_MINE, "shipped": SECTION_BUNDLED, "gone": SECTION_TRASH}
selected = controller.state.selected
assert selected["name"] == "mine" and selected["isCurrent"]
assert not selected["canDelete"] and "Close the tab" in selected["deleteReason"]
def test_library_delete_and_undo(make_tabs):
tabs = make_tabs()
_save(tabs, "closed")
tabs.new_workspace()
_save(tabs, "open_one")
tabs.close_workspace(0)
controller = ProfileLibraryController(ProfileActions(tabs.current_dock_area()))
controller.select("p:closed")
controller.request_delete()
assert controller.state.pending_delete == "closed"
controller.confirm_delete()
assert "closed" not in list_profiles(NAMESPACE)
assert controller.state.banner.action == "Undo"
assert any(row["kind"] == "trash" for row in controller.state.rows)
controller.banner_action()
assert "closed" in list_profiles(NAMESPACE)
assert not any(row["kind"] == "trash" for row in controller.state.rows)
def test_library_search_and_pin(make_tabs):
tabs = make_tabs()
_save(tabs, "alpha")
_save(tabs, "beta")
set_profile_notes("beta", "tomography setup", NAMESPACE)
controller = ProfileLibraryController(ProfileActions(tabs.current_dock_area()))
controller.set_query("tomo")
assert [row["name"] for row in controller.state.rows] == ["beta"]
assert controller.state.selected["name"] == "beta"
controller.set_query("zzz")
assert controller.state.rows == [] and "zzz" in controller.state.empty_text
controller.set_query("")
assert is_quick_select("alpha", NAMESPACE)
controller.toggle_pin("p:alpha")
assert not is_quick_select("alpha", NAMESPACE)
def test_library_open_in_new_tab_and_here(make_tabs):
tabs = make_tabs()
_save(tabs, "one")
tabs.new_workspace()
_save(tabs, "two")
tabs.close_workspace(1)
controller = ProfileLibraryController(ProfileActions(tabs.current_dock_area()))
controller.select("p:two")
controller.open_selected(new_tab=True)
assert tabs.workspace_names() == ["one", "two"]
assert tabs.currentIndex() == 1
tabs.close_workspace(1)
controller.refresh()
controller.select("p:two")
controller.open_selected(new_tab=False)
assert tabs.workspace_names() == ["two"]
def test_library_rename_keeps_or_moves_old_profile(make_tabs):
tabs = make_tabs()
_save(tabs, "first")
controller = ProfileLibraryController(ProfileActions(tabs.current_dock_area()))
assert controller.apply_name("rename", "first", "second")
assert tabs.workspace_names() == ["second"]
assert {"first", "second"} <= set(list_profiles(NAMESPACE))
POLICY.rename_keeps_old_profile = False
assert controller.apply_name("rename", "second", "third")
assert tabs.workspace_names() == ["third"]
assert "second" not in list_profiles(NAMESPACE)
assert "second" in [t.name for t in list_trashed_profiles(NAMESPACE)]
def test_library_rename_closed_profile_copies_it(make_tabs):
tabs = make_tabs()
_save(tabs, "closed")
tabs.current_dock_area()._current_profile_name = None
controller = ProfileLibraryController(ProfileActions(tabs.current_dock_area()))
assert controller.apply_name("rename", "closed", "renamed")
assert "renamed" in list_profiles(NAMESPACE)
def test_library_duplicate_and_replace_goes_to_trash(make_tabs):
tabs = make_tabs()
_save(tabs, "base")
_save(tabs, "target")
_save(tabs, "base")
actions = ProfileActions(tabs.current_dock_area())
text = actions.duplicate("base", "target")
assert "Recently deleted" in text
assert [t.name for t in list_trashed_profiles(NAMESPACE)] == ["target"]
assert "target" in list_profiles(NAMESPACE)
def test_library_revert(make_tabs):
tabs = make_tabs()
dock_area = tabs.current_dock_area()
dock_area.new(WIDGET)
_save(tabs, "rev")
dock_area.new(WIDGET)
assert len(dock_area.dock_list()) == 2
controller = ProfileLibraryController(ProfileActions(dock_area))
assert controller.state.selected["canRevert"]
controller.apply_revert("rev")
assert len(tabs.current_dock_area().dock_list()) == 1
################################################################################
# Views and wiring
################################################################################
@pytest.mark.parametrize("ui", ["qwidget", "qml"])
def test_library_window_opens_from_toolbar(make_tabs, qtbot, ui):
tabs = make_tabs(ui)
_save(tabs, "shown")
dock_area = tabs.current_dock_area()
dock_area.show_workspace_manager()
dialog = dock_area.manage_dialog
assert dialog is not None and dialog.isVisible()
assert dialog.controller.state.selected["name"] == "shown"
if ui == "qml":
view = dialog.view
assert view.status() == QQuickWidget.Status.Ready, view.errors()
assert dialog.backend.rows.rowCount() == 1
else:
assert dialog.library.name_label.text() == "shown"
dialog.close()
qtbot.waitUntil(lambda: dock_area.manage_dialog is None)
@pytest.mark.parametrize("ui", ["qwidget", "qml"])
def test_toolbar_save_uses_name_dialog(make_tabs, monkeypatch, ui):
tabs = make_tabs(ui)
cls = profile_qml.ProfileNameDialog if ui == "qml" else profile_qwidget.ProfileNameDialog
_accept_with(monkeypatch, cls, "from_dialog")
tabs.current_dock_area().save_profile_dialog()
assert tabs.workspace_names() == ["from_dialog"]
@pytest.mark.parametrize("ui", ["qwidget", "qml"])
def test_name_dialog_blocks_read_only_names(make_tabs, isolate_profile_storage, monkeypatch, ui):
tabs = make_tabs(ui)
_bundle(tabs, isolate_profile_storage, "shipped")
cls = profile_qml.ProfileNameDialog if ui == "qml" else profile_qwidget.ProfileNameDialog
_accept_with(monkeypatch, cls, "shipped")
assert ask_profile_name(tabs.current_dock_area(), "save") is None
assert tabs.workspace_names() == [None]
def test_qwidget_name_dialog_states(make_tabs, qtbot):
tabs = make_tabs()
_save(tabs, "taken")
tabs.current_dock_area()._current_profile_name = None
dialog = profile_qwidget.ProfileNameDialog(ProfileActions(tabs.current_dock_area()), "save")
qtbot.addWidget(dialog)
assert not dialog.confirm_button.isEnabled()
dialog.name_edit.setText("taken")
assert dialog.confirm_button.isEnabled() and dialog.confirm_button.text() == "Replace"
dialog.name_edit.setText("bad:name")
assert not dialog.confirm_button.isEnabled()
dialog.name_edit.setText("fresh")
assert dialog.confirm_button.text() == "Save"
def test_tab_rename_uses_name_dialog(make_tabs, monkeypatch):
tabs = make_tabs()
_save(tabs, "tab_old")
_accept_with(monkeypatch, profile_qwidget.ProfileNameDialog, "tab_new")
assert tabs.rename_workspace(0)
assert tabs.workspace_names() == ["tab_new"]
def test_revert_from_toolbar_asks_with_revert_dialog(make_tabs, monkeypatch):
tabs = make_tabs()
dock_area = tabs.current_dock_area()
_save(tabs, "rev")
dock_area.new(WIDGET)
asked = []
monkeypatch.setattr(
profile_qwidget.RevertProfileDialog,
"exec",
lambda self: asked.append(True) or QDialog.DialogCode.Rejected,
)
dock_area.restore_baseline_profile(show_dialog=True)
assert asked and len(dock_area.dock_list()) == 1
def test_one_tab_policy_can_be_relaxed(make_tabs):
tabs = make_tabs()
_save(tabs, "shared")
tabs.new_workspace()
page = tabs.widget(1)
assert tabs._is_open_elsewhere("shared", page)
POLICY.one_tab_per_profile = False
assert not tabs._is_open_elsewhere("shared", page)
def test_library_created_directly(make_tabs, qtbot):
tabs = make_tabs()
dialog = create_profile_library(tabs.current_dock_area(), ui="qml")
qtbot.addWidget(dialog)
assert dialog.controller.state.empty_text
dialog.close()
+269
View File
@@ -0,0 +1,269 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, redefined-outer-name
import pytest
from qtpy.QtWidgets import QInputDialog, QMessageBox
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.containers.dock_area.profile_utils import (
get_open_workspaces,
list_profiles,
set_open_workspaces,
)
from bec_widgets.widgets.containers.dock_area.workspace_tabs import (
UNTITLED_WORKSPACE,
WorkspaceTabs,
)
NAMESPACE = "tabs_test"
@pytest.fixture(autouse=True)
def isolate_profile_storage(tmp_path, monkeypatch):
root = tmp_path / "profiles_root"
root.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(root))
yield
@pytest.fixture
def make_tabs(qtbot, mocked_client):
created = []
def _make(**kwargs):
tabs = WorkspaceTabs(profile_namespace=NAMESPACE, client=mocked_client, **kwargs)
qtbot.addWidget(tabs)
tabs.show()
qtbot.waitExposed(tabs)
created.append(tabs)
return tabs
yield _make
for tabs in created:
tabs.cleanup()
def _save_profile(tabs, name):
"""Save the current tab under *name* without dialogs."""
tabs.current_dock_area().save_profile(name, show_dialog=False)
def test_starts_with_one_untitled_workspace(make_tabs):
tabs = make_tabs()
assert tabs.count() == 1
assert tabs.tabText(0) == UNTITLED_WORKSPACE
assert tabs.current_dock_area() is not None
assert tabs.workspace_names() == [None]
def test_new_workspace_adds_and_switches_tab(make_tabs):
tabs = make_tabs()
first = tabs.current_dock_area()
tabs.new_workspace()
assert tabs.count() == 2
assert tabs.currentIndex() == 1
second = tabs.current_dock_area()
assert second is not first
assert tabs.dock_areas() == [first, second]
def test_each_tab_keeps_its_own_layout(make_tabs):
tabs = make_tabs()
tabs.current_dock_area().new("RingProgressBar")
tabs.new_workspace()
assert tabs.current_dock_area().dock_list() == []
tabs.setCurrentIndex(0)
assert len(tabs.current_dock_area().dock_list()) == 1
def test_saving_names_the_tab_and_persists_open_tabs(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
assert tabs.tabText(0) == "alignment"
tabs.new_workspace()
_save_profile(tabs, "scans")
assert get_open_workspaces(NAMESPACE) == (["alignment", "scans"], "scans")
tabs.setCurrentIndex(0)
assert get_open_workspaces(NAMESPACE) == (["alignment", "scans"], "alignment")
def test_unsaved_tabs_are_not_persisted(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
assert get_open_workspaces(NAMESPACE) == (["alignment"], None)
def test_restores_open_tabs_lazily(make_tabs):
tabs = make_tabs()
tabs.current_dock_area().new("RingProgressBar")
_save_profile(tabs, "alignment")
tabs.new_workspace()
_save_profile(tabs, "scans")
tabs.setCurrentIndex(0)
tabs.cleanup()
restored = make_tabs()
assert [restored.tabText(i) for i in range(restored.count())] == ["alignment", "scans"]
assert restored.currentIndex() == 0
# Only the active tab builds its dock area at startup
assert [page.is_materialized for page in restored.pages()] == [True, False]
assert len(restored.current_dock_area().dock_list()) == 1
restored.setCurrentIndex(1)
assert restored.pages()[1].is_materialized
assert restored.current_dock_area()._current_profile_name == "scans"
def test_restore_skips_missing_profiles(make_tabs):
set_open_workspaces(["gone", "also_gone"], "gone", namespace=NAMESPACE)
tabs = make_tabs()
assert tabs.count() == 1
assert tabs.workspace_names() == [None]
def test_restore_is_scoped_to_known_profiles(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.cleanup()
set_open_workspaces(["alignment", "gone"], "gone", namespace=NAMESPACE)
restored = make_tabs()
assert restored.workspace_names() == ["alignment"]
def test_open_workspace_switches_to_existing_tab(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
tabs.open_workspace("alignment")
assert tabs.count() == 2
assert tabs.currentIndex() == 0
def test_open_workspace_opens_saved_profile_in_new_tab(make_tabs):
tabs = make_tabs()
tabs.current_dock_area().new("RingProgressBar")
_save_profile(tabs, "alignment")
tabs.new_workspace()
_save_profile(tabs, "scans")
tabs.close_workspace(0)
assert tabs.workspace_names() == ["scans"]
tabs.open_workspace("alignment")
assert tabs.workspace_names() == ["scans", "alignment"]
assert len(tabs.current_dock_area().dock_list()) == 1
def test_loading_profile_open_in_other_tab_switches_instead(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
_save_profile(tabs, "scans")
tabs.current_dock_area().load_profile("alignment")
assert tabs.currentIndex() == 0
assert tabs.workspace_names() == ["alignment", "scans"]
def test_saving_under_name_open_in_other_tab_is_refused(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
_save_profile(tabs, "alignment")
assert tabs.workspace_names() == ["alignment", None]
def test_close_workspace_keeps_profile_on_disk(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
_save_profile(tabs, "scans")
assert tabs.close_workspace(0)
assert tabs.workspace_names() == ["scans"]
assert "alignment" in list_profiles(NAMESPACE)
assert get_open_workspaces(NAMESPACE) == (["scans"], "scans")
def test_closing_last_tab_leaves_empty_workspace(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.close_workspace(0)
assert tabs.count() == 1
assert tabs.workspace_names() == [None]
assert "alignment" in list_profiles(NAMESPACE)
def test_close_unsaved_workspace_with_widgets_asks_first(make_tabs, monkeypatch):
tabs = make_tabs()
tabs.current_dock_area().new("RingProgressBar")
tabs.new_workspace()
monkeypatch.setattr(
QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.No
)
assert not tabs.close_workspace(0)
assert tabs.count() == 2
monkeypatch.setattr(
QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.Yes
)
assert tabs.close_workspace(0)
assert tabs.count() == 1
def test_rename_saves_under_new_name_and_keeps_old_profile(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
assert tabs.rename_workspace(0, "alignment_v2")
assert tabs.tabText(0) == "alignment_v2"
profiles = list_profiles(NAMESPACE)
assert "alignment" in profiles and "alignment_v2" in profiles
assert get_open_workspaces(NAMESPACE) == (["alignment_v2"], "alignment_v2")
def test_rename_untitled_workspace_saves_it(make_tabs, monkeypatch):
tabs = make_tabs()
monkeypatch.setattr(QInputDialog, "getText", lambda *args, **kwargs: ("beamtime", True))
assert tabs.rename_workspace()
assert tabs.tabText(0) == "beamtime"
assert "beamtime" in list_profiles(NAMESPACE)
def test_rename_to_name_open_in_other_tab_is_refused(make_tabs, monkeypatch):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
infos = []
monkeypatch.setattr(QMessageBox, "information", lambda *args, **kwargs: infos.append(args))
assert not tabs.rename_workspace(1, "alignment")
assert infos
assert tabs.workspace_names() == ["alignment", None]
def test_rename_over_existing_saved_profile_asks_first(make_tabs, monkeypatch):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.close_workspace(0)
monkeypatch.setattr(
QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.No
)
assert not tabs.rename_workspace(0, "alignment")
assert tabs.workspace_names() == [None]
def test_moving_tabs_updates_persisted_order(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
_save_profile(tabs, "scans")
tabs.tabBar().moveTab(1, 0)
assert get_open_workspaces(NAMESPACE)[0] == ["scans", "alignment"]
def test_open_menu_lists_saved_profiles(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs._populate_open_menu()
texts = [action.text() for action in tabs._open_menu.actions()]
assert "New Workspace" in texts
assert "alignment" in texts