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