diff --git a/bec_widgets/applications/views/dock_area_view/dock_area_view.py b/bec_widgets/applications/views/dock_area_view/dock_area_view.py index cb143d6a..6281cefb 100644 --- a/bec_widgets/applications/views/dock_area_view/dock_area_view.py +++ b/bec_widgets/applications/views/dock_area_view/dock_area_view.py @@ -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() diff --git a/bec_widgets/utils/quick/qml/BecUi/Card.qml b/bec_widgets/utils/quick/qml/BecUi/Card.qml index eadf5d56..2451958f 100644 --- a/bec_widgets/utils/quick/qml/BecUi/Card.qml +++ b/bec_widgets/utils/quick/qml/BecUi/Card.qml @@ -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 } } diff --git a/bec_widgets/utils/quick/qml/BecUi/SelectField.qml b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml index d2149dce..2d4f814b 100644 --- a/bec_widgets/utils/quick/qml/BecUi/SelectField.qml +++ b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml @@ -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 diff --git a/bec_widgets/utils/ux_kit.py b/bec_widgets/utils/ux_kit.py index 11719f28..c8497afc 100644 --- a/bec_widgets/utils/ux_kit.py +++ b/bec_widgets/utils/ux_kit.py @@ -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() diff --git a/bec_widgets/widgets/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py index 1455ce45..1f5bb3fa 100644 --- a/bec_widgets/widgets/containers/dock_area/dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -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 diff --git a/bec_widgets/widgets/containers/dock_area/profile_utils.py b/bec_widgets/widgets/containers/dock_area/profile_utils.py index a73928c2..db7d2227 100644 --- a/bec_widgets/widgets/containers/dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/dock_area/profile_utils.py @@ -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 diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/__init__.py b/bec_widgets/widgets/containers/dock_area/profile_ux/__init__.py new file mode 100644 index 00000000..55e0d14b --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/__init__.py @@ -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) diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/common.py b/bec_widgets/widgets/containers/dock_area/profile_ux/common.py new file mode 100644 index 00000000..c6ae310a --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/common.py @@ -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_``, ``base__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) diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/profile_qml.py b/bec_widgets/widgets/containers/dock_area/profile_ux/profile_qml.py new file mode 100644 index 00000000..b2560285 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/profile_qml.py @@ -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/?``.""" + + 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 diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/profile_qwidget.py b/bec_widgets/widgets/containers/dock_area/profile_ux/profile_qwidget.py new file mode 100644 index 00000000..7f24f6b8 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/profile_qwidget.py @@ -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 diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/qml/HintLine.qml b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/HintLine.qml new file mode 100644 index 00000000..3080b3d9 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/HintLine.qml @@ -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 + } +} diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/qml/PreviewFrame.qml b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/PreviewFrame.qml new file mode 100644 index 00000000..6994b66e --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/PreviewFrame.qml @@ -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 + } +} diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/qml/ProfileLibrary.qml b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/ProfileLibrary.qml new file mode 100644 index 00000000..4acee492 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/ProfileLibrary.qml @@ -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() + } + } + } + } + } + } + } +} diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/qml/ProfileNameDialog.qml b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/ProfileNameDialog.qml new file mode 100644 index 00000000..57ac04d1 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/ProfileNameDialog.qml @@ -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() + } + } + } +} diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/qml/RevertProfileDialog.qml b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/RevertProfileDialog.qml new file mode 100644 index 00000000..0f8bfb76 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/RevertProfileDialog.qml @@ -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() + } + } + } +} diff --git a/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py index b3d46df9..b233c035 100644 --- a/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py @@ -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) diff --git a/bec_widgets/widgets/containers/dock_area/workspace_tabs.py b/bec_widgets/widgets/containers/dock_area/workspace_tabs.py new file mode 100644 index 00000000..918b97fb --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/workspace_tabs.py @@ -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() diff --git a/tests/unit_tests/test_main_app.py b/tests/unit_tests/test_main_app.py index ebdeb33c..39e98d27 100644 --- a/tests/unit_tests/test_main_app.py +++ b/tests/unit_tests/test_main_app.py @@ -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 diff --git a/tests/unit_tests/test_profile_ux.py b/tests/unit_tests/test_profile_ux.py new file mode 100644 index 00000000..af9d6146 --- /dev/null +++ b/tests/unit_tests/test_profile_ux.py @@ -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() diff --git a/tests/unit_tests/test_workspace_tabs.py b/tests/unit_tests/test_workspace_tabs.py new file mode 100644 index 00000000..68178b2d --- /dev/null +++ b/tests/unit_tests/test_workspace_tabs.py @@ -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