From 1a65152554dc3b26ebaf7b34c733ccf5e5d2033c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:26:31 +0000 Subject: [PATCH 1/3] feat(main_app): open workspaces as tabs in the dock area view --- .../views/dock_area_view/dock_area_view.py | 46 +- .../widgets/containers/dock_area/dock_area.py | 25 +- .../containers/dock_area/profile_utils.py | 56 +++ .../containers/dock_area/workspace_tabs.py | 468 ++++++++++++++++++ tests/unit_tests/test_main_app.py | 15 +- tests/unit_tests/test_workspace_tabs.py | 269 ++++++++++ 6 files changed, 868 insertions(+), 11 deletions(-) create mode 100644 bec_widgets/widgets/containers/dock_area/workspace_tabs.py create mode 100644 tests/unit_tests/test_workspace_tabs.py diff --git a/bec_widgets/applications/views/dock_area_view/dock_area_view.py b/bec_widgets/applications/views/dock_area_view/dock_area_view.py index cb143d6a..6281cefb 100644 --- a/bec_widgets/applications/views/dock_area_view/dock_area_view.py +++ b/bec_widgets/applications/views/dock_area_view/dock_area_view.py @@ -1,15 +1,20 @@ from qtpy.QtWidgets import QWidget -from bec_widgets.applications.views.view import ViewBase +from bec_widgets.applications.views.view import ViewBase, ViewTourSteps from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea +from bec_widgets.widgets.containers.dock_area.workspace_tabs import WorkspaceTabs class DockAreaView(ViewBase): """ Modular dock area view for arranging and managing multiple dockable widgets. + + Each workspace lives in its own tab with its own dock area and layout. RPC calls are + forwarded to the dock area of the current tab. """ RPC_CONTENT_CLASS = BECDockArea + RPC_CONTENT_ATTR = "dock_area" def __init__( self, @@ -21,11 +26,36 @@ class DockAreaView(ViewBase): **kwargs, ): super().__init__(parent=parent, content=content, view_id=view_id, title=title, **kwargs) - self.dock_area = BECDockArea( - self, - profile_namespace="bec", - auto_profile_namespace=False, - object_name="DockArea", - rpc_exposed=False, + self.workspaces = WorkspaceTabs(self, profile_namespace="bec", rpc_exposed=False) + self.set_content(self.workspaces) + + @property + def dock_area(self) -> BECDockArea | None: + """The dock area of the current workspace tab.""" + return self.workspaces.current_dock_area() + + def register_tour_steps(self, guided_tour, main_app): + """Register the current dock area's components with the guided tour.""" + tabs = self.workspaces + + def get_workspace_tabs(): + main_app.set_current("dock_area") + return (tabs.tabBar(), None) + + step_id = guided_tour.register_widget( + widget=get_workspace_tabs, + title="Workspace Tabs", + text="Each tab is its own workspace with its own layout. Use + to open a new or " + "saved workspace, double-click a tab to rename it, and drag tabs to reorder them. " + "Open tabs are restored on the next start.", ) - self.set_content(self.dock_area) + dock_area = self.dock_area + view_tour = dock_area.register_tour_steps(guided_tour, main_app) if dock_area else None + if view_tour is None: + return ViewTourSteps(view_title="Dock Area Workspace", step_ids=[step_id]) + view_tour.step_ids.insert(0, step_id) + return view_tour + + def cleanup(self): + self.workspaces.cleanup() + super().cleanup() diff --git a/bec_widgets/widgets/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py index 8d7e5f54..1f34bfb1 100644 --- a/bec_widgets/widgets/containers/dock_area/dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -2,7 +2,7 @@ from __future__ import annotations import os from functools import lru_cache -from typing import Literal, Mapping, Sequence +from typing import Callable, Literal, Mapping, Sequence import slugify from bec_lib import bec_logger @@ -124,6 +124,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, @@ -145,6 +147,8 @@ 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 super().__init__( parent, default_add_direction=default_add_direction, title="BEC Dock Area", **kwargs ) @@ -660,6 +664,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) @@ -844,6 +852,14 @@ 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" @@ -914,8 +930,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): diff --git a/bec_widgets/widgets/containers/dock_area/profile_utils.py b/bec_widgets/widgets/containers/dock_area/profile_utils.py index 53564b6f..6401465c 100644 --- a/bec_widgets/widgets/containers/dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/dock_area/profile_utils.py @@ -469,6 +469,8 @@ SETTINGS_KEYS = { "screenshot": "profile/screenshot", "screenshot_at": "profile/screenshot_at", "last_profile": "app/last_profile", + "open_workspaces": "app/open_workspaces", + "current_workspace": "app/current_workspace", } @@ -664,6 +666,60 @@ def set_last_profile( s.remove(key) +def _workspace_tabs_key(key: str, namespace: str | None) -> str: + ns = slugify.slugify(namespace, separator="_") if namespace else None + base = SETTINGS_KEYS[key] + return f"{base}/{ns}" if ns else base + + +def get_open_workspaces(namespace: str | None = None) -> tuple[list[str], str | None]: + """ + Retrieve the workspace tabs that were open when the app last persisted them. + + Args: + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + tuple[list[str], str | None]: Profile names in tab order and the profile + of the active tab (``None`` when unknown). + """ + s = _app_settings() + raw = s.value(_workspace_tabs_key("open_workspaces", namespace), []) + if isinstance(raw, str): + raw = [raw] if raw else [] + names: list[str] = [] + for name in raw or []: + name = str(name).strip() + if name and name not in names: + names.append(name) + current = s.value(_workspace_tabs_key("current_workspace", namespace), "", type=str) + return names, (current or None) + + +def set_open_workspaces( + names: list[str], current: str | None = None, namespace: str | None = None +) -> None: + """ + Persist the open workspace tabs (profile names in tab order) and the active one. + + Args: + names (list[str]): Profile names in tab order. Unsaved workspaces are left out. + current (str | None, optional): Profile of the active tab. Defaults to ``None``. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + """ + s = _app_settings() + key = _workspace_tabs_key("open_workspaces", namespace) + if names: + s.setValue(key, list(names)) + else: + s.remove(key) + current_key = _workspace_tabs_key("current_workspace", namespace) + if current: + s.setValue(current_key, current) + else: + s.remove(current_key) + + def is_experimental_features_enabled() -> bool: """ Check whether experimental features are enabled globally. diff --git a/bec_widgets/widgets/containers/dock_area/workspace_tabs.py b/bec_widgets/widgets/containers/dock_area/workspace_tabs.py new file mode 100644 index 00000000..df4f216d --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/workspace_tabs.py @@ -0,0 +1,468 @@ +"""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, + is_profile_read_only, + list_profiles, + profile_origin, + set_open_workspaces, +) + +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) -> 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. + + 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: + 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 not self._may_rename_to(name): + 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) + 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: + 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") + + def _show_tab_menu(self, pos: QPoint) -> None: + index = self.tabBar().tabAt(pos) + if index < 0: + return + menu = QMenu(self) + 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)) diff --git a/tests/unit_tests/test_main_app.py b/tests/unit_tests/test_main_app.py index ebdeb33c..39e98d27 100644 --- a/tests/unit_tests/test_main_app.py +++ b/tests/unit_tests/test_main_app.py @@ -151,7 +151,8 @@ def test_view_switch_method_switches_to_target(app_with_spies, qtbot): def test_view_content_widget_is_hidden_from_namespace(app_with_spies): app, _, _, _ = app_with_spies - assert app.dock_area.content is app.dock_area.dock_area + assert app.dock_area.content is app.dock_area.workspaces + assert app.dock_area.dock_area is app.dock_area.workspaces.current_dock_area() def test_developer_view_is_experimental_by_default(monkeypatch, tmp_path, qtbot, mocked_client): @@ -305,3 +306,15 @@ def test_guided_tour_can_start_and_stop(app_with_spies, qtbot): # Check that tour is stopped assert not app.guided_tour._active + + +def test_dock_area_view_rpc_targets_current_workspace_tab(app_with_spies): + app, _, _, _ = app_with_spies + view = app.dock_area + first = view.dock_area + view.workspaces.new_workspace() + second = getattr(view, type(view).RPC_CONTENT_ATTR) + assert second is view.workspaces.current_dock_area() + assert second is not first + view.workspaces.setCurrentIndex(0) + assert getattr(view, type(view).RPC_CONTENT_ATTR) is first diff --git a/tests/unit_tests/test_workspace_tabs.py b/tests/unit_tests/test_workspace_tabs.py new file mode 100644 index 00000000..68178b2d --- /dev/null +++ b/tests/unit_tests/test_workspace_tabs.py @@ -0,0 +1,269 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, redefined-outer-name +import pytest +from qtpy.QtWidgets import QInputDialog, QMessageBox + +from bec_widgets.tests.client_mocks import mocked_client +from bec_widgets.widgets.containers.dock_area.profile_utils import ( + get_open_workspaces, + list_profiles, + set_open_workspaces, +) +from bec_widgets.widgets.containers.dock_area.workspace_tabs import ( + UNTITLED_WORKSPACE, + WorkspaceTabs, +) + +NAMESPACE = "tabs_test" + + +@pytest.fixture(autouse=True) +def isolate_profile_storage(tmp_path, monkeypatch): + root = tmp_path / "profiles_root" + root.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(root)) + yield + + +@pytest.fixture +def make_tabs(qtbot, mocked_client): + created = [] + + def _make(**kwargs): + tabs = WorkspaceTabs(profile_namespace=NAMESPACE, client=mocked_client, **kwargs) + qtbot.addWidget(tabs) + tabs.show() + qtbot.waitExposed(tabs) + created.append(tabs) + return tabs + + yield _make + for tabs in created: + tabs.cleanup() + + +def _save_profile(tabs, name): + """Save the current tab under *name* without dialogs.""" + tabs.current_dock_area().save_profile(name, show_dialog=False) + + +def test_starts_with_one_untitled_workspace(make_tabs): + tabs = make_tabs() + assert tabs.count() == 1 + assert tabs.tabText(0) == UNTITLED_WORKSPACE + assert tabs.current_dock_area() is not None + assert tabs.workspace_names() == [None] + + +def test_new_workspace_adds_and_switches_tab(make_tabs): + tabs = make_tabs() + first = tabs.current_dock_area() + tabs.new_workspace() + assert tabs.count() == 2 + assert tabs.currentIndex() == 1 + second = tabs.current_dock_area() + assert second is not first + assert tabs.dock_areas() == [first, second] + + +def test_each_tab_keeps_its_own_layout(make_tabs): + tabs = make_tabs() + tabs.current_dock_area().new("RingProgressBar") + tabs.new_workspace() + assert tabs.current_dock_area().dock_list() == [] + tabs.setCurrentIndex(0) + assert len(tabs.current_dock_area().dock_list()) == 1 + + +def test_saving_names_the_tab_and_persists_open_tabs(make_tabs): + tabs = make_tabs() + _save_profile(tabs, "alignment") + assert tabs.tabText(0) == "alignment" + tabs.new_workspace() + _save_profile(tabs, "scans") + assert get_open_workspaces(NAMESPACE) == (["alignment", "scans"], "scans") + + tabs.setCurrentIndex(0) + assert get_open_workspaces(NAMESPACE) == (["alignment", "scans"], "alignment") + + +def test_unsaved_tabs_are_not_persisted(make_tabs): + tabs = make_tabs() + _save_profile(tabs, "alignment") + tabs.new_workspace() + assert get_open_workspaces(NAMESPACE) == (["alignment"], None) + + +def test_restores_open_tabs_lazily(make_tabs): + tabs = make_tabs() + tabs.current_dock_area().new("RingProgressBar") + _save_profile(tabs, "alignment") + tabs.new_workspace() + _save_profile(tabs, "scans") + tabs.setCurrentIndex(0) + tabs.cleanup() + + restored = make_tabs() + assert [restored.tabText(i) for i in range(restored.count())] == ["alignment", "scans"] + assert restored.currentIndex() == 0 + # Only the active tab builds its dock area at startup + assert [page.is_materialized for page in restored.pages()] == [True, False] + assert len(restored.current_dock_area().dock_list()) == 1 + + restored.setCurrentIndex(1) + assert restored.pages()[1].is_materialized + assert restored.current_dock_area()._current_profile_name == "scans" + + +def test_restore_skips_missing_profiles(make_tabs): + set_open_workspaces(["gone", "also_gone"], "gone", namespace=NAMESPACE) + tabs = make_tabs() + assert tabs.count() == 1 + assert tabs.workspace_names() == [None] + + +def test_restore_is_scoped_to_known_profiles(make_tabs): + tabs = make_tabs() + _save_profile(tabs, "alignment") + tabs.cleanup() + set_open_workspaces(["alignment", "gone"], "gone", namespace=NAMESPACE) + + restored = make_tabs() + assert restored.workspace_names() == ["alignment"] + + +def test_open_workspace_switches_to_existing_tab(make_tabs): + tabs = make_tabs() + _save_profile(tabs, "alignment") + tabs.new_workspace() + tabs.open_workspace("alignment") + assert tabs.count() == 2 + assert tabs.currentIndex() == 0 + + +def test_open_workspace_opens_saved_profile_in_new_tab(make_tabs): + tabs = make_tabs() + tabs.current_dock_area().new("RingProgressBar") + _save_profile(tabs, "alignment") + tabs.new_workspace() + _save_profile(tabs, "scans") + tabs.close_workspace(0) + assert tabs.workspace_names() == ["scans"] + + tabs.open_workspace("alignment") + assert tabs.workspace_names() == ["scans", "alignment"] + assert len(tabs.current_dock_area().dock_list()) == 1 + + +def test_loading_profile_open_in_other_tab_switches_instead(make_tabs): + tabs = make_tabs() + _save_profile(tabs, "alignment") + tabs.new_workspace() + _save_profile(tabs, "scans") + + tabs.current_dock_area().load_profile("alignment") + + assert tabs.currentIndex() == 0 + assert tabs.workspace_names() == ["alignment", "scans"] + + +def test_saving_under_name_open_in_other_tab_is_refused(make_tabs): + tabs = make_tabs() + _save_profile(tabs, "alignment") + tabs.new_workspace() + _save_profile(tabs, "alignment") + assert tabs.workspace_names() == ["alignment", None] + + +def test_close_workspace_keeps_profile_on_disk(make_tabs): + tabs = make_tabs() + _save_profile(tabs, "alignment") + tabs.new_workspace() + _save_profile(tabs, "scans") + assert tabs.close_workspace(0) + assert tabs.workspace_names() == ["scans"] + assert "alignment" in list_profiles(NAMESPACE) + assert get_open_workspaces(NAMESPACE) == (["scans"], "scans") + + +def test_closing_last_tab_leaves_empty_workspace(make_tabs): + tabs = make_tabs() + _save_profile(tabs, "alignment") + tabs.close_workspace(0) + assert tabs.count() == 1 + assert tabs.workspace_names() == [None] + assert "alignment" in list_profiles(NAMESPACE) + + +def test_close_unsaved_workspace_with_widgets_asks_first(make_tabs, monkeypatch): + tabs = make_tabs() + tabs.current_dock_area().new("RingProgressBar") + tabs.new_workspace() + monkeypatch.setattr( + QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.No + ) + assert not tabs.close_workspace(0) + assert tabs.count() == 2 + + monkeypatch.setattr( + QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.Yes + ) + assert tabs.close_workspace(0) + assert tabs.count() == 1 + + +def test_rename_saves_under_new_name_and_keeps_old_profile(make_tabs): + tabs = make_tabs() + _save_profile(tabs, "alignment") + assert tabs.rename_workspace(0, "alignment_v2") + assert tabs.tabText(0) == "alignment_v2" + profiles = list_profiles(NAMESPACE) + assert "alignment" in profiles and "alignment_v2" in profiles + assert get_open_workspaces(NAMESPACE) == (["alignment_v2"], "alignment_v2") + + +def test_rename_untitled_workspace_saves_it(make_tabs, monkeypatch): + tabs = make_tabs() + monkeypatch.setattr(QInputDialog, "getText", lambda *args, **kwargs: ("beamtime", True)) + assert tabs.rename_workspace() + assert tabs.tabText(0) == "beamtime" + assert "beamtime" in list_profiles(NAMESPACE) + + +def test_rename_to_name_open_in_other_tab_is_refused(make_tabs, monkeypatch): + tabs = make_tabs() + _save_profile(tabs, "alignment") + tabs.new_workspace() + infos = [] + monkeypatch.setattr(QMessageBox, "information", lambda *args, **kwargs: infos.append(args)) + assert not tabs.rename_workspace(1, "alignment") + assert infos + assert tabs.workspace_names() == ["alignment", None] + + +def test_rename_over_existing_saved_profile_asks_first(make_tabs, monkeypatch): + tabs = make_tabs() + _save_profile(tabs, "alignment") + tabs.close_workspace(0) + monkeypatch.setattr( + QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.No + ) + assert not tabs.rename_workspace(0, "alignment") + assert tabs.workspace_names() == [None] + + +def test_moving_tabs_updates_persisted_order(make_tabs): + tabs = make_tabs() + _save_profile(tabs, "alignment") + tabs.new_workspace() + _save_profile(tabs, "scans") + tabs.tabBar().moveTab(1, 0) + assert get_open_workspaces(NAMESPACE)[0] == ["scans", "alignment"] + + +def test_open_menu_lists_saved_profiles(make_tabs): + tabs = make_tabs() + _save_profile(tabs, "alignment") + tabs._populate_open_menu() + texts = [action.text() for action in tabs._open_menu.actions()] + assert "New Workspace" in texts + assert "alignment" in texts From 11cb5bfa3f5f742c46e1940b1820f41098d61463 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:29:59 +0000 Subject: [PATCH 2/3] chore(quick): bring in the shared QML host and ux kit from claude/qml-widget-ports-s7h02q Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Kns5hgJ1MEXNfFeCuTESFP --- bec_widgets/utils/quick/__init__.py | 26 ++ bec_widgets/utils/quick/host.py | 233 ++++++++++ bec_widgets/utils/quick/list_model.py | 83 ++++ bec_widgets/utils/quick/qml/BecUi/Card.qml | 56 +++ .../utils/quick/qml/BecUi/FieldFrame.qml | 12 + bec_widgets/utils/quick/qml/BecUi/Icon.qml | 25 ++ .../utils/quick/qml/BecUi/IconButton.qml | 36 ++ .../utils/quick/qml/BecUi/InputField.qml | 38 ++ .../utils/quick/qml/BecUi/SelectField.qml | 88 ++++ .../utils/quick/qml/BecUi/StatusPill.qml | 40 ++ .../utils/quick/qml/BecUi/SwitchField.qml | 34 ++ .../utils/quick/qml/BecUi/TextButton.qml | 65 +++ bec_widgets/utils/quick/qml/BecUi/qmldir | 10 + bec_widgets/utils/ux_kit.py | 420 ++++++++++++++++++ 14 files changed, 1166 insertions(+) create mode 100644 bec_widgets/utils/quick/__init__.py create mode 100644 bec_widgets/utils/quick/host.py create mode 100644 bec_widgets/utils/quick/list_model.py create mode 100644 bec_widgets/utils/quick/qml/BecUi/Card.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/Icon.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/IconButton.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/InputField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/SelectField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/StatusPill.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/SwitchField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/TextButton.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/qmldir create mode 100644 bec_widgets/utils/ux_kit.py diff --git a/bec_widgets/utils/quick/__init__.py b/bec_widgets/utils/quick/__init__.py new file mode 100644 index 00000000..dcc683f1 --- /dev/null +++ b/bec_widgets/utils/quick/__init__.py @@ -0,0 +1,26 @@ +"""Shared plumbing for widgets that render their content with Qt Quick (QML). + +The QML ports keep the usual ``BECWidget`` shell and public API and only swap the rendering +layer: a :class:`~qtpy.QtQuickWidgets.QQuickWidget` is embedded into the existing widget and +driven by a small Python backend object. +""" + +from bec_widgets.utils.quick.host import ( + QML_IMPORT_PATH, + MaterialIconProvider, + QmlTheme, + create_quick_widget, + quick_engine, + release_quick_widget, +) +from bec_widgets.utils.quick.list_model import DictListModel + +__all__ = [ + "QML_IMPORT_PATH", + "DictListModel", + "MaterialIconProvider", + "QmlTheme", + "create_quick_widget", + "quick_engine", + "release_quick_widget", +] diff --git a/bec_widgets/utils/quick/host.py b/bec_widgets/utils/quick/host.py new file mode 100644 index 00000000..0df3b172 --- /dev/null +++ b/bec_widgets/utils/quick/host.py @@ -0,0 +1,233 @@ +"""Hosting helpers for QML views embedded into BEC widgets. + +All QML views share one :class:`QQmlEngine` per application. The engine carries the +application-wide ``theme`` context property (a :class:`QmlTheme` bridging ``app.theme`` from +``bec_qthemes``), an ``image://material/`` provider for Material icons and the import path +of the shared ``BecUi`` control module. Per-widget state is handed to the view through initial +properties of the root item, typically a single ``backend`` QObject. +""" + +from __future__ import annotations + +from pathlib import Path +from urllib.parse import parse_qs + +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy.QtCore import Property, QObject, QSize, Qt, QUrl, Signal +from qtpy.QtGui import QColor, QPixmap +from qtpy.QtQml import QQmlEngine +from qtpy.QtQuick import QQuickImageProvider +from qtpy.QtQuickWidgets import QQuickWidget +from qtpy.QtWidgets import QApplication, QWidget + +logger = bec_logger.logger + +QML_IMPORT_PATH = Path(__file__).parent / "qml" + +_DARK_FALLBACK = { + "PRIMARY": "#3b82f6", + "ON_PRIMARY": "#ffffff", + "BG": "#0f1115", + "CARD_BG": "#171a21", + "FIELD_BG": "#10131a", + "BORDER": "#2a2f3a", + "FG": "#e8ebf1", + "ACCENT_DEFAULT": "#8ab4f7", + "ACCENT_HIGHLIGHT": "#B53565", + "ACCENT_WARNING": "#EAC435", + "ACCENT_EMERGENCY": "#CC181E", + "ACCENT_SUCCESS": "#2CA58D", +} + + +def _blend(base: QColor, top: QColor, amount: float) -> QColor: + """Return ``top`` mixed into ``base`` by ``amount`` (0 = base, 1 = top).""" + return QColor.fromRgbF( + base.redF() + (top.redF() - base.redF()) * amount, + base.greenF() + (top.greenF() - base.greenF()) * amount, + base.blueF() + (top.blueF() - base.blueF()) * amount, + 1.0, + ) + + +class ThemeTokens: + """Resolved colour tokens of the active BEC theme, shared by the QML and QWidget ports.""" + + def __init__(self): + app = QApplication.instance() + theme = getattr(app, "theme", None) if app is not None else None + name = getattr(theme, "theme", "dark") if theme is not None else "dark" + + def color(key: str) -> QColor: + fallback = _DARK_FALLBACK.get(key, "#808080") + if theme is None: + return QColor(fallback) + return QColor(theme.color(key, fallback)) + + self.name: str = str(name) + self.bg = color("BG") + self.card = color("CARD_BG") + self.field = color("FIELD_BG") + self.border = color("BORDER") + self.fg = color("FG") + self.primary = color("PRIMARY") + self.on_primary = color("ON_PRIMARY") + self.accent = color("ACCENT_DEFAULT") + self.highlight = color("ACCENT_HIGHLIGHT") + self.warning = color("ACCENT_WARNING") + self.danger = color("ACCENT_EMERGENCY") + self.success = color("ACCENT_SUCCESS") + self.dark = self.bg.lightnessF() < 0.5 + self.fg_muted = _blend(self.card, self.fg, 0.62) + self.fg_subtle = _blend(self.card, self.fg, 0.4) + self.hover = _blend(self.card, self.fg, 0.06) + self.pressed = _blend(self.card, self.fg, 0.12) + self.track = _blend(self.card, self.fg, 0.1) + + def soft(self, color: QColor, amount: float = 0.16) -> QColor: + """Tint ``color`` onto the card background, e.g. for chips and banners.""" + return _blend(self.card, color, amount) + + +class QmlTheme(QObject): + """Expose the BEC theme colours to QML as ``theme.`` and follow theme changes.""" + + changed = Signal() + + def __init__(self, parent: QObject | None = None): + super().__init__(parent) + self._tokens = ThemeTokens() + self._connected_theme = None + self._connect_theme() + + def _connect_theme(self) -> None: + app = QApplication.instance() + theme = getattr(app, "theme", None) if app is not None else None + if theme is None or theme is self._connected_theme: + return + theme.theme_changed.connect(self.refresh) + self._connected_theme = theme + + def refresh(self, *_args) -> None: + """Re-read the tokens from the application theme.""" + self._connect_theme() + self._tokens = ThemeTokens() + self.changed.emit() + + # pylint: disable=missing-function-docstring + def _get(self, name: str) -> QColor: + return getattr(self._tokens, name) + + name = Property(str, lambda self: self._tokens.name, notify=changed) + dark = Property(bool, lambda self: self._tokens.dark, notify=changed) + bg = Property(QColor, lambda self: self._get("bg"), notify=changed) + card = Property(QColor, lambda self: self._get("card"), notify=changed) + field = Property(QColor, lambda self: self._get("field"), notify=changed) + border = Property(QColor, lambda self: self._get("border"), notify=changed) + fg = Property(QColor, lambda self: self._get("fg"), notify=changed) + fgMuted = Property(QColor, lambda self: self._get("fg_muted"), notify=changed) + fgSubtle = Property(QColor, lambda self: self._get("fg_subtle"), notify=changed) + hover = Property(QColor, lambda self: self._get("hover"), notify=changed) + pressed = Property(QColor, lambda self: self._get("pressed"), notify=changed) + track = Property(QColor, lambda self: self._get("track"), notify=changed) + primary = Property(QColor, lambda self: self._get("primary"), notify=changed) + onPrimary = Property(QColor, lambda self: self._get("on_primary"), notify=changed) + accent = Property(QColor, lambda self: self._get("accent"), notify=changed) + highlight = Property(QColor, lambda self: self._get("highlight"), notify=changed) + warning = Property(QColor, lambda self: self._get("warning"), notify=changed) + danger = Property(QColor, lambda self: self._get("danger"), notify=changed) + success = Property(QColor, lambda self: self._get("success"), notify=changed) + + +class MaterialIconProvider(QQuickImageProvider): + """Serve Material icons to QML as ``image://material/?color=%23rrggbb&filled=1``.""" + + def __init__(self): + super().__init__(QQuickImageProvider.ImageType.Pixmap) + + def requestPixmap(self, icon_id: str, size: QSize, requested_size: QSize) -> QPixmap: + name, _, query = icon_id.partition("?") + options = parse_qs(query) + color = options.get("color", [None])[0] + filled = options.get("filled", ["0"])[0] in ("1", "true") + target = requested_size if requested_size.isValid() else QSize(24, 24) + if target.width() <= 0 or target.height() <= 0: + target = QSize(24, 24) + pixmap = material_icon(name, size=target, color=color, filled=filled) + if size is not None: + size.setWidth(pixmap.width()) + size.setHeight(pixmap.height()) + return pixmap + + +_ENGINE: QQmlEngine | None = None +_THEME: QmlTheme | None = None + + +def quick_engine() -> QQmlEngine: + """Return the application-wide QML engine used by all BEC QML views.""" + global _ENGINE, _THEME # pylint: disable=global-statement + if _ENGINE is not None: + if _THEME._connected_theme is None: # pylint: disable=protected-access + # the theme was applied after the engine was created + _THEME.refresh() + return _ENGINE + app = QApplication.instance() + _ENGINE = QQmlEngine(app) + _ENGINE.addImportPath(str(QML_IMPORT_PATH)) + _ENGINE.addImageProvider("material", MaterialIconProvider()) + _THEME = QmlTheme(_ENGINE) + _ENGINE.rootContext().setContextProperty("theme", _THEME) + _ENGINE.warnings.connect( + lambda warnings: [logger.warning(f"QML: {w.toString()}") for w in warnings] + ) + if app is not None: + app.aboutToQuit.connect(_drop_engine) + return _ENGINE + + +def _drop_engine() -> None: + global _ENGINE, _THEME # pylint: disable=global-statement + _ENGINE = None + _THEME = None + + +def create_quick_widget( + parent: QWidget, qml_file: str | Path, properties: dict | None = None +) -> QQuickWidget: + """Create a QQuickWidget showing ``qml_file`` on the shared engine. + + Args: + parent(QWidget): Parent widget, usually the BEC widget shell. + qml_file(str | Path): Path of the root QML file. + properties(dict | None): Initial properties of the root item, e.g. ``{"backend": obj}``. + + Returns: + QQuickWidget: The view, already loaded. Load errors are logged. + """ + view = QQuickWidget(quick_engine(), parent) + view.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) + view.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) + view.setClearColor(parent.palette().window().color()) + if properties: + view.setInitialProperties(properties) + view.setSource(QUrl.fromLocalFile(str(qml_file))) + for error in view.errors(): + logger.error(f"QML error in {qml_file}: {error.toString()}") + return view + + +def release_quick_widget(view: QQuickWidget | None) -> None: + """Unload the QML scene of ``view`` before its backend objects are destroyed. + + Without this, bindings of a still-loaded scene are re-evaluated against already deleted + backends during teardown and log ``TypeError`` warnings. + """ + if view is None: + return + try: + view.setSource(QUrl()) + except RuntimeError: + # the C++ object is already gone + return diff --git a/bec_widgets/utils/quick/list_model.py b/bec_widgets/utils/quick/list_model.py new file mode 100644 index 00000000..6351b93d --- /dev/null +++ b/bec_widgets/utils/quick/list_model.py @@ -0,0 +1,83 @@ +"""A small list model that feeds lists of dictionaries to QML views.""" + +from __future__ import annotations + +from typing import Any, Sequence + +from qtpy.QtCore import Property, QAbstractListModel, QByteArray, QModelIndex, Qt, Signal + + +class DictListModel(QAbstractListModel): + """List model whose rows are plain dictionaries with a fixed set of keys. + + Each key becomes a QML role. :meth:`set_items` updates rows in place when the row count is + unchanged, so QML delegates are kept alive and ``Behavior`` animations run on value changes + instead of the delegates being rebuilt. + + Args: + roles(Sequence[str]): Keys of the row dictionaries exposed as roles. + parent: Parent QObject. + """ + + count_changed = Signal() + + def __init__(self, roles: Sequence[str], parent=None): + super().__init__(parent) + self._keys = list(roles) + self._roles = {Qt.ItemDataRole.UserRole + 1 + i: key for i, key in enumerate(self._keys)} + self._items: list[dict[str, Any]] = [] + + # pylint: disable=invalid-name + def roleNames(self) -> dict[int, QByteArray]: + return {role: QByteArray(key.encode()) for role, key in self._roles.items()} + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + if parent.isValid(): + return 0 + return len(self._items) + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): + if not index.isValid() or not 0 <= index.row() < len(self._items): + return None + key = self._roles.get(role) + if key is None: + return None + return self._items[index.row()].get(key) + + @property + def items(self) -> list[dict[str, Any]]: + """Copy of the current rows.""" + return [dict(item) for item in self._items] + + def set_items(self, items: Sequence[dict[str, Any]]) -> None: + """Replace the rows, reusing existing rows where possible. + + Args: + items(Sequence[dict]): New rows. + """ + items = [dict(item) for item in items] + old_count = len(self._items) + new_count = len(items) + common = min(old_count, new_count) + for row in range(common): + changed = [ + role + for role, key in self._roles.items() + if self._items[row].get(key) != items[row].get(key) + ] + self._items[row] = items[row] + if changed: + index = self.index(row, 0) + self.dataChanged.emit(index, index, changed) + if new_count > old_count: + self.beginInsertRows(QModelIndex(), old_count, new_count - 1) + self._items.extend(items[old_count:]) + self.endInsertRows() + elif new_count < old_count: + self.beginRemoveRows(QModelIndex(), new_count, old_count - 1) + del self._items[new_count:] + self.endRemoveRows() + if new_count != old_count: + self.count_changed.emit() + + count = Property(int, lambda self: len(self._items), notify=count_changed) diff --git a/bec_widgets/utils/quick/qml/BecUi/Card.qml b/bec_widgets/utils/quick/qml/BecUi/Card.qml new file mode 100644 index 00000000..2451958f --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Card.qml @@ -0,0 +1,56 @@ +import QtQuick +import QtQuick.Layouts + +// Rounded surface with an optional title row. Children go into the content column. +Rectangle { + id: root + property string title: "" + property string subtitle: "" + default property alias content: column.data + 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 + border.color: theme.border + border.width: 1 + implicitHeight: outer.implicitHeight + 2 * padding + implicitWidth: outer.implicitWidth + 2 * padding + + ColumnLayout { + id: outer + anchors.fill: parent + anchors.margins: root.padding + spacing: 10 + RowLayout { + visible: root.title !== "" || extras.children.length > 0 + Layout.fillWidth: true + spacing: 8 + Text { + text: root.title + color: theme.fg + font.pixelSize: 13 + font.weight: Font.DemiBold + } + Text { + text: root.subtitle + visible: text !== "" + color: theme.fgSubtle + font.pixelSize: 12 + Layout.fillWidth: true + elide: Text.ElideRight + } + Item { Layout.fillWidth: root.subtitle === "" } + Row { id: extras; spacing: 4 } + } + ColumnLayout { + id: column + Layout.fillWidth: true + Layout.fillHeight: root.fillContent + spacing: 8 + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml b/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml new file mode 100644 index 00000000..aad88d6a --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Background used by all input controls: field colour, border, focus and error states. +Rectangle { + property bool focused: false + property bool invalid: false + property bool hovered: false + radius: 6 + color: theme.field + border.width: focused || invalid ? 2 : 1 + border.color: invalid ? theme.danger : focused ? theme.primary : hovered ? theme.fgSubtle : theme.border +} diff --git a/bec_widgets/utils/quick/qml/BecUi/Icon.qml b/bec_widgets/utils/quick/qml/BecUi/Icon.qml new file mode 100644 index 00000000..d9fbedc4 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Icon.qml @@ -0,0 +1,25 @@ +import QtQuick + +// Material icon served by the "material" image provider, tinted with the given colour. +Item { + id: root + property string name: "" + property color color: theme.fg + property bool filled: false + property int size: 18 + + implicitWidth: size + implicitHeight: size + + Image { + anchors.centerIn: parent + width: root.size + height: root.size + sourceSize.width: root.size * 2 + sourceSize.height: root.size * 2 + smooth: true + fillMode: Image.PreserveAspectFit + source: root.name === "" ? "" : "image://material/" + root.name + "?color=" + + encodeURIComponent(root.color.toString()) + (root.filled ? "&filled=1" : "") + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/IconButton.qml b/bec_widgets/utils/quick/qml/BecUi/IconButton.qml new file mode 100644 index 00000000..9603aa81 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/IconButton.qml @@ -0,0 +1,36 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Flat, square icon button with hover and pressed feedback and a tooltip. +AbstractButton { + id: root + property string iconName: "" + property color iconColor: theme.fgMuted + property string tip: "" + property int iconSize: 18 + + implicitWidth: 30 + implicitHeight: 30 + hoverEnabled: true + focusPolicy: Qt.TabFocus + opacity: enabled ? 1.0 : 0.4 + Accessible.name: tip + + background: Rectangle { + radius: 6 + color: root.down ? theme.pressed : (root.hovered || root.checked ? theme.hover : "transparent") + border.width: root.visualFocus ? 1 : 0 + border.color: theme.primary + } + contentItem: Item { + Icon { + anchors.centerIn: parent + name: root.iconName + size: root.iconSize + color: root.checked ? theme.primary : root.iconColor + } + } + ToolTip.visible: tip !== "" && hovered + ToolTip.delay: 500 + ToolTip.text: tip +} diff --git a/bec_widgets/utils/quick/qml/BecUi/InputField.qml b/bec_widgets/utils/quick/qml/BecUi/InputField.qml new file mode 100644 index 00000000..0ed06d5f --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/InputField.qml @@ -0,0 +1,38 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Single-line text input with optional unit suffix and error state. +TextField { + id: root + property bool invalid: false + property string suffix: "" + + implicitHeight: 32 + implicitWidth: 120 + color: theme.fg + placeholderTextColor: theme.fgSubtle + selectionColor: theme.primary + selectedTextColor: theme.onPrimary + font.pixelSize: 13 + leftPadding: 9 + rightPadding: suffixLabel.visible ? suffixLabel.implicitWidth + 14 : 9 + selectByMouse: true + hoverEnabled: true + opacity: enabled ? 1.0 : 0.5 + + background: FieldFrame { + focused: root.activeFocus + invalid: root.invalid + hovered: root.hovered + } + Text { + id: suffixLabel + visible: root.suffix !== "" + text: root.suffix + color: theme.fgSubtle + font.pixelSize: 12 + anchors.right: parent.right + anchors.rightMargin: 9 + anchors.verticalCenter: parent.verticalCenter + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SelectField.qml b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml new file mode 100644 index 00000000..2d4f814b --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml @@ -0,0 +1,88 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Combo box styled like the input fields. With editable: true it filters while typing. +ComboBox { + id: root + property bool invalid: false + property string placeholder: "" + + implicitHeight: 32 + implicitWidth: 140 + font.pixelSize: 13 + hoverEnabled: true + opacity: enabled ? 1.0 : 0.5 + + background: FieldFrame { + focused: root.activeFocus || root.popup.visible + invalid: root.invalid + hovered: root.hovered + } + contentItem: TextField { + leftPadding: 9 + 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 + selectedTextColor: theme.onPrimary + font: root.font + verticalAlignment: Text.AlignVCenter + background: null + selectByMouse: true + // forward clicks to the combo when it is not editable + TapHandler { enabled: !root.editable; onTapped: root.popup.visible ? root.popup.close() : root.popup.open() } + } + indicator: Icon { + x: root.width - width - 6 + y: (root.height - height) / 2 + name: "expand_more" + size: 18 + color: theme.fgMuted + } + delegate: ItemDelegate { + id: option + required property int index + required property var modelData + width: ListView.view ? ListView.view.width : root.width + height: 30 + highlighted: root.highlightedIndex === index + contentItem: Text { + text: option.modelData === "" ? "None" : option.modelData + color: option.modelData === "" ? theme.fgSubtle : theme.fg + font.pixelSize: 13 + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: option.highlighted ? theme.hover : "transparent" + Rectangle { + width: 3; height: parent.height - 10; radius: 1.5 + anchors.verticalCenter: parent.verticalCenter + color: theme.primary + visible: root.currentIndex === option.index + } + } + } + popup: Popup { + y: root.height + 2 + width: root.width + implicitHeight: Math.min(contentItem.implicitHeight + 8, 280) + padding: 4 + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: root.popup.visible ? root.delegateModel : null + currentIndex: root.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator {} + } + background: Rectangle { + color: theme.card + border.color: theme.border + radius: 8 + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml b/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml new file mode 100644 index 00000000..7901c832 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml @@ -0,0 +1,40 @@ +import QtQuick + +// Small rounded status chip with a coloured dot. Set pulse for live states such as "Moving". +Rectangle { + id: root + property string text: "" + property color tone: theme.fgMuted + property bool pulse: false + + implicitHeight: 22 + implicitWidth: label.implicitWidth + 26 + radius: height / 2 + color: Qt.rgba(tone.r, tone.g, tone.b, 0.16) + + Rectangle { + id: dot + width: 7; height: 7; radius: 3.5 + color: root.tone + anchors.left: parent.left + anchors.leftMargin: 9 + anchors.verticalCenter: parent.verticalCenter + SequentialAnimation on opacity { + running: root.pulse + loops: Animation.Infinite + alwaysRunToEnd: true + NumberAnimation { to: 0.25; duration: 550; easing.type: Easing.InOutQuad } + NumberAnimation { to: 1.0; duration: 550; easing.type: Easing.InOutQuad } + } + } + Text { + id: label + anchors.left: dot.right + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: root.text + color: root.tone + font.pixelSize: 12 + font.weight: Font.DemiBold + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml b/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml new file mode 100644 index 00000000..a21f3221 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml @@ -0,0 +1,34 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Toggle switch for boolean parameters. +Switch { + id: root + implicitHeight: 32 + hoverEnabled: true + padding: 0 + indicator: Rectangle { + implicitWidth: 38 + implicitHeight: 22 + x: root.leftPadding + y: (root.height - height) / 2 + radius: 11 + color: root.checked ? theme.primary : theme.track + border.color: root.visualFocus ? theme.primary : (root.checked ? theme.primary : theme.border) + Behavior on color { ColorAnimation { duration: 120 } } + Rectangle { + x: root.checked ? parent.width - width - 3 : 3 + y: 3 + width: 16; height: 16; radius: 8 + color: root.checked ? theme.onPrimary : theme.fgMuted + Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + } + } + contentItem: Text { + leftPadding: root.indicator.width + 8 + text: root.text + color: theme.fg + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/TextButton.qml b/bec_widgets/utils/quick/qml/BecUi/TextButton.qml new file mode 100644 index 00000000..45b069dc --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/TextButton.qml @@ -0,0 +1,65 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +// Labelled button. variant: "primary", "danger", "success", "neutral" or "ghost". +AbstractButton { + id: root + property string variant: "neutral" + property string iconName: "" + property string tip: "" + property bool busy: false + + readonly property color baseColor: variant === "primary" ? theme.primary + : variant === "danger" ? theme.danger + : variant === "success" ? theme.success + : "transparent" + readonly property bool solid: variant === "primary" || variant === "danger" || variant === "success" + readonly property color labelColor: solid ? "white" : theme.fg + + implicitHeight: 32 + implicitWidth: Math.max(64, row.implicitWidth + 24) + hoverEnabled: true + opacity: enabled ? 1.0 : 0.45 + Accessible.name: text + + background: Rectangle { + radius: 6 + color: root.solid ? (root.down ? Qt.darker(root.baseColor, 1.25) : root.hovered ? Qt.lighter(root.baseColor, 1.1) : root.baseColor) + : (root.down ? theme.pressed : root.hovered ? theme.hover : "transparent") + border.width: root.variant === "neutral" || root.visualFocus ? 1 : 0 + border.color: root.visualFocus ? theme.primary : theme.border + Behavior on color { ColorAnimation { duration: 90 } } + } + contentItem: Item { + implicitWidth: row.implicitWidth + implicitHeight: row.implicitHeight + RowLayout { + id: row + anchors.centerIn: parent + spacing: 6 + BusyIndicator { + visible: root.busy + running: root.busy + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + } + Icon { + visible: root.iconName !== "" && !root.busy + name: root.iconName + color: root.labelColor + size: 16 + } + Text { + text: root.text + color: root.labelColor + font.pixelSize: 13 + font.weight: root.solid ? Font.DemiBold : Font.Medium + elide: Text.ElideRight + } + } + } + ToolTip.visible: tip !== "" && hovered + ToolTip.delay: 500 + ToolTip.text: tip +} diff --git a/bec_widgets/utils/quick/qml/BecUi/qmldir b/bec_widgets/utils/quick/qml/BecUi/qmldir new file mode 100644 index 00000000..160907bc --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/qmldir @@ -0,0 +1,10 @@ +module BecUi +Icon 1.0 Icon.qml +IconButton 1.0 IconButton.qml +TextButton 1.0 TextButton.qml +StatusPill 1.0 StatusPill.qml +Card 1.0 Card.qml +FieldFrame 1.0 FieldFrame.qml +InputField 1.0 InputField.qml +SelectField 1.0 SelectField.qml +SwitchField 1.0 SwitchField.qml diff --git a/bec_widgets/utils/ux_kit.py b/bec_widgets/utils/ux_kit.py new file mode 100644 index 00000000..c8497afc --- /dev/null +++ b/bec_widgets/utils/ux_kit.py @@ -0,0 +1,420 @@ +"""Small themed QWidget controls used by the QWidget twins of the QML widget ports. + +They mirror the ``BecUi`` QML controls in ``bec_widgets/utils/quick/qml`` so that the QML and +QWidget versions of a widget look and behave the same and can be compared side by side. Colours +come from the same :class:`~bec_widgets.utils.quick.host.ThemeTokens`. +""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, QSize, Qt +from qtpy.QtGui import QColor, QFont, QPainter +from qtpy.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.quick.host import ThemeTokens + + +def rgba(color: QColor, alpha: float) -> str: + """Return a QSS ``rgba()`` string for ``color`` with alpha in 0..1.""" + return f"rgba({color.red()}, {color.green()}, {color.blue()}, {int(alpha * 255)})" + + +def refresh_kit_theme(root: QWidget) -> ThemeTokens: + """Re-theme every kit control below ``root`` and return the current tokens.""" + tokens = ThemeTokens() + for widget in [root, *root.findChildren(QWidget)]: + refresh = getattr(widget, "refresh_theme", None) + if callable(refresh) and widget is not root: + refresh(tokens) + return tokens + + +def field_qss(tokens: ThemeTokens) -> str: + """Stylesheet for inputs inside kit cards; ``invalid=true`` marks a field with an error.""" + return f""" + QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {{ + background: {tokens.field.name()}; + color: {tokens.fg.name()}; + border: 1px solid {tokens.border.name()}; + border-radius: 6px; + padding: 0px 8px; + min-height: 30px; + font-size: 13px; + selection-background-color: {tokens.primary.name()}; + }} + QLineEdit:hover, QComboBox:hover, QSpinBox:hover, QDoubleSpinBox:hover {{ + border-color: {tokens.fg_subtle.name()}; + }} + QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus {{ + border: 2px solid {tokens.primary.name()}; + padding: 0px 7px; + }} + QLineEdit[invalid="true"], QComboBox[invalid="true"] {{ + border: 2px solid {tokens.danger.name()}; + padding: 0px 7px; + }} + QLineEdit:disabled, QComboBox:disabled {{ color: {tokens.fg_subtle.name()}; }} + QComboBox::drop-down {{ border: none; width: 22px; }} + """ + + +def set_invalid(widget: QWidget, invalid: bool) -> None: + """Toggle the ``invalid`` style of a field styled with :func:`field_qss`.""" + if bool(widget.property("invalid")) == invalid: + return + widget.setProperty("invalid", invalid) + widget.style().unpolish(widget) + widget.style().polish(widget) + + +class IconButton(QToolButton): + """Flat square icon button with hover feedback, matching ``BecUi.IconButton``.""" + + def __init__(self, icon_name: str, tip: str = "", parent: QWidget | None = None, size=18): + super().__init__(parent) + self._icon_name = icon_name + self._icon_size = size + self.setToolTip(tip) + self.setAccessibleName(tip) + self.setAutoRaise(True) + self.setFixedSize(30, 30) + self.setIconSize(QSize(size, size)) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.refresh_theme(ThemeTokens()) + + def set_icon_name(self, icon_name: str) -> None: + """Change the Material icon.""" + self._icon_name = icon_name + self.refresh_theme(ThemeTokens()) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + color = tokens.primary if self.isChecked() else tokens.fg_muted + self.setIcon( + material_icon(self._icon_name, size=(48, 48), color=color, convert_to_pixmap=False) + ) + self.setStyleSheet( + f"QToolButton {{ border: none; border-radius: 6px; background: transparent; }}" + f"QToolButton:hover, QToolButton:checked {{ background: {tokens.hover.name()}; }}" + f"QToolButton:pressed {{ background: {tokens.pressed.name()}; }}" + ) + + +class TextButton(QPushButton): + """Labelled button; variant is ``primary``, ``danger``, ``success``, ``neutral`` or ``ghost``.""" + + def __init__( + self, + text: str, + variant: str = "neutral", + icon_name: str = "", + parent: QWidget | None = None, + ): + super().__init__(text, parent) + self._variant = variant + self._icon_name = icon_name + self.setMinimumHeight(32) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.refresh_theme(ThemeTokens()) + + def set_variant(self, variant: str) -> None: + """Switch the colour variant.""" + self._variant = variant + self.refresh_theme(ThemeTokens()) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + base = {"primary": tokens.primary, "danger": tokens.danger, "success": tokens.success}.get( + self._variant + ) + if base is not None: + label = QColor("white") + qss = ( + f"QPushButton {{ background: {base.name()}; color: white; border: none;" + f" border-radius: 6px; padding: 0 12px; font-size: 13px; font-weight: 600; }}" + f"QPushButton:hover {{ background: {base.lighter(110).name()}; }}" + f"QPushButton:pressed {{ background: {base.darker(125).name()}; }}" + f"QPushButton:disabled {{ background: {rgba(base, 0.45)};" + f" color: {rgba(label, 0.7)}; }}" + ) + else: + label = tokens.fg + border = tokens.border.name() if self._variant == "neutral" else "transparent" + qss = ( + f"QPushButton {{ background: transparent; color: {tokens.fg.name()};" + f" border: 1px solid {border}; border-radius: 6px; padding: 0 12px;" + f" font-size: 13px; font-weight: 500; }}" + f"QPushButton:hover {{ background: {tokens.hover.name()}; }}" + f"QPushButton:pressed {{ background: {tokens.pressed.name()}; }}" + f"QPushButton:disabled {{ color: {tokens.fg_subtle.name()}; }}" + ) + self.setStyleSheet(qss) + if self._icon_name: + self.setIcon( + material_icon(self._icon_name, size=(32, 32), color=label, convert_to_pixmap=False) + ) + self.setIconSize(QSize(16, 16)) + + +class StatusPill(QWidget): + """Rounded status chip with a coloured dot, matching ``BecUi.StatusPill``.""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self._text = "" + self._tone = QColor("#808080") + self._dot_opacity = 1.0 + self._pulse = QPropertyAnimation(self, b"dot_opacity", self) + self._pulse.setDuration(1100) + self._pulse.setKeyValueAt(0.0, 1.0) + self._pulse.setKeyValueAt(0.5, 0.25) + self._pulse.setKeyValueAt(1.0, 1.0) + self._pulse.setEasingCurve(QEasingCurve.Type.InOutQuad) + self._pulse.setLoopCount(-1) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + font = self.font() + font.setPixelSize(12) + font.setWeight(QFont.Weight.DemiBold) + self.setFont(font) + + def set_status(self, text: str, tone: QColor, pulse: bool = False) -> None: + """Set label, colour and whether the dot pulses.""" + self._text = text + self._tone = QColor(tone) + if pulse and self._pulse.state() != QPropertyAnimation.State.Running: + self._pulse.start() + elif not pulse: + self._pulse.stop() + self._dot_opacity = 1.0 + self.setToolTip(text) + self.updateGeometry() + self.update() + + @property + def text(self) -> str: + """Current label.""" + return self._text + + @property + def pulsing(self) -> bool: + """Whether the dot animation runs.""" + return self._pulse.state() == QPropertyAnimation.State.Running + + def _get_dot_opacity(self) -> float: + return self._dot_opacity + + def _set_dot_opacity(self, value: float) -> None: + self._dot_opacity = value + self.update() + + dot_opacity = Property(float, _get_dot_opacity, _set_dot_opacity) + + def sizeHint(self) -> QSize: # pylint: disable=invalid-name + return QSize(self.fontMetrics().horizontalAdvance(self._text) + 26, 22) + + def paintEvent(self, _event): # pylint: disable=invalid-name + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(self.rect()) + background = QColor(self._tone) + background.setAlphaF(0.16) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(background) + painter.drawRoundedRect(rect, rect.height() / 2, rect.height() / 2) + dot = QColor(self._tone) + dot.setAlphaF(self._dot_opacity) + painter.setBrush(dot) + painter.drawEllipse(QRectF(9, rect.height() / 2 - 3.5, 7, 7)) + painter.setPen(self._tone) + painter.drawText(rect.adjusted(22, 0, 0, 0), Qt.AlignmentFlag.AlignVCenter, self._text) + painter.end() + + def cleanup(self) -> None: + """Stop the pulse animation.""" + self._pulse.stop() + + +class Card(QFrame): + """Rounded surface with an optional title row, matching ``BecUi.Card``.""" + + def __init__(self, title: str = "", parent: QWidget | None = None, padding: int = 12): + super().__init__(parent) + self.setObjectName("uxCard") + outer = QVBoxLayout(self) + outer.setContentsMargins(padding, padding, padding, padding) + outer.setSpacing(10) + self.header = QHBoxLayout() + self.header.setSpacing(8) + self.title_label = QLabel(title, self) + self.subtitle_label = QLabel("", self) + self.subtitle_label.setVisible(False) + self.header.addWidget(self.title_label) + self.header.addWidget(self.subtitle_label, 1) + self.header_extras = QHBoxLayout() + self.header_extras.setSpacing(4) + self.header.addLayout(self.header_extras) + outer.addLayout(self.header) + self.body = QVBoxLayout() + self.body.setSpacing(8) + outer.addLayout(self.body) + self._has_title = bool(title) + self._update_header_visibility() + self.refresh_theme(ThemeTokens()) + + def set_title(self, title: str, subtitle: str = "") -> None: + """Set the title and an optional muted subtitle.""" + self._has_title = bool(title) + self.title_label.setText(title) + self.subtitle_label.setText(subtitle) + self.subtitle_label.setVisible(bool(subtitle)) + self._update_header_visibility() + + def add_header_widget(self, widget: QWidget) -> None: + """Add a widget to the right end of the title row.""" + self.header_extras.addWidget(widget) + self._has_title = True + self._update_header_visibility() + + def _update_header_visibility(self) -> None: + self.title_label.setVisible(bool(self.title_label.text())) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + self.setStyleSheet( + f"QFrame#uxCard {{ background: {tokens.card.name()};" + f" border: 1px solid {tokens.border.name()}; border-radius: 10px; }}" + ) + self.title_label.setStyleSheet( + f"color: {tokens.fg.name()}; font-size: 13px; font-weight: 600; background: none;" + ) + self.subtitle_label.setStyleSheet( + f"color: {tokens.fg_subtle.name()}; font-size: 12px; background: none;" + ) + + +class PortedPropertiesMixin: + """Make a port export and load the ``SafeProperty`` settings of the class it ports. + + ``BECConnector`` only collects the properties defined directly on ``type(self)``, so a + subclass would silently drop the inherited ones (e.g. ``ring_json``) from saved profiles. + Set ``PORTED_FROM`` to the original class; its properties and those of every class in + between are collected. + """ + + PORTED_FROM: type | None = None + + def _get_bec_meta_objects(self) -> dict: + objects = {} + for cls in type(self).__mro__: + for name, attr in vars(cls).items(): + if name in objects or not isinstance(attr, Property): + continue + if getattr(attr.fget, "__is_safe_getter__", False): + objects[name] = attr + 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() From 3c0c1bae6afc7377aa3e6957b1bb32fafd1a4947 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 22:26:56 +0000 Subject: [PATCH 3/3] feat(dock_area): profile library, name dialog and revert dialog in QWidget and QML with inline checks and undoable delete --- .../widgets/containers/dock_area/dock_area.py | 44 +- .../containers/dock_area/profile_utils.py | 196 ++++ .../dock_area/profile_ux/__init__.py | 96 ++ .../containers/dock_area/profile_ux/common.py | 942 ++++++++++++++++++ .../dock_area/profile_ux/profile_qml.py | 449 +++++++++ .../dock_area/profile_ux/profile_qwidget.py | 940 +++++++++++++++++ .../dock_area/profile_ux/qml/HintLine.qml | 35 + .../dock_area/profile_ux/qml/PreviewFrame.qml | 50 + .../profile_ux/qml/ProfileLibrary.qml | 400 ++++++++ .../profile_ux/qml/ProfileNameDialog.qml | 124 +++ .../profile_ux/qml/RevertProfileDialog.qml | 101 ++ .../toolbar_components/workspace_actions.py | 31 +- .../containers/dock_area/workspace_tabs.py | 81 +- tests/unit_tests/test_profile_ux.py | 460 +++++++++ 14 files changed, 3932 insertions(+), 17 deletions(-) create mode 100644 bec_widgets/widgets/containers/dock_area/profile_ux/__init__.py create mode 100644 bec_widgets/widgets/containers/dock_area/profile_ux/common.py create mode 100644 bec_widgets/widgets/containers/dock_area/profile_ux/profile_qml.py create mode 100644 bec_widgets/widgets/containers/dock_area/profile_ux/profile_qwidget.py create mode 100644 bec_widgets/widgets/containers/dock_area/profile_ux/qml/HintLine.qml create mode 100644 bec_widgets/widgets/containers/dock_area/profile_ux/qml/PreviewFrame.qml create mode 100644 bec_widgets/widgets/containers/dock_area/profile_ux/qml/ProfileLibrary.qml create mode 100644 bec_widgets/widgets/containers/dock_area/profile_ux/qml/ProfileNameDialog.qml create mode 100644 bec_widgets/widgets/containers/dock_area/profile_ux/qml/RevertProfileDialog.qml create mode 100644 tests/unit_tests/test_profile_ux.py diff --git a/bec_widgets/widgets/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py index 1f34bfb1..7d9da522 100644 --- a/bec_widgets/widgets/containers/dock_area/dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -63,6 +63,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, @@ -149,6 +155,8 @@ class BECDockArea(DockAreaWidget): 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 ) @@ -169,6 +177,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) @@ -464,7 +473,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) @@ -802,6 +815,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 "" @@ -863,10 +880,10 @@ class BECDockArea(DockAreaWidget): 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) @@ -1010,7 +1027,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() @@ -1156,6 +1176,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 @@ -1177,8 +1207,10 @@ class BECDockArea(DockAreaWidget): manage_action.setChecked(True) # keep it toggle def _manage_dialog_closed(self): - self.manage_widget.close() - self.manage_widget.deleteLater() + if self.manage_widget is not None: + self.manage_widget.close() + self.manage_widget.deleteLater() + self.manage_widget = None if self.manage_dialog is not None: self.manage_dialog.deleteLater() self.manage_dialog = None diff --git a/bec_widgets/widgets/containers/dock_area/profile_utils.py b/bec_widgets/widgets/containers/dock_area/profile_utils.py index 6401465c..22359db1 100644 --- a/bec_widgets/widgets/containers/dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/dock_area/profile_utils.py @@ -1170,3 +1170,199 @@ def load_runtime_profile_screenshot(name: str, namespace: str | None = None) -> if s is None: return None return _load_screenshot_from_settings(s) + + +################################################## +# Profile library helpers +################################################## + +TRASH_DIR_NAME = "_trash" + + +def set_profile_notes(name: str, notes: str, namespace: str | None = None) -> None: + """ + Store a short free-text description of a profile in its runtime and saved copies. + + Args: + name (str): Profile name without extension. + notes (str): The description; an empty string clears it. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + """ + notes = (notes or "").strip() + targets = [open_runtime_settings(name, namespace)] + if not is_profile_read_only(name, namespace) and _existing_baseline_settings(name, namespace): + targets.append(open_baseline_settings(name, namespace)) + for settings in targets: + if notes: + settings.setValue("profile/notes", notes) + else: + settings.remove("profile/notes") + settings.sync() + + +def has_saved_baseline(name: str, namespace: str | None = None) -> bool: + """ + Check whether a profile has a saved copy that its working copy can be reverted to. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + bool: ``True`` when a baseline copy exists. + """ + return any(os.path.exists(path) for path in baseline_profile_candidates(name, namespace)) + + +def copy_profile(source: str, target: str, namespace: str | None = None) -> None: + """ + Copy a stored profile under a new name, as working copy and saved copy. + + The working (runtime) copy of *source* is preferred so the copy shows what the user last + saw. The copy is a regular user profile, even when *source* is a bundled one. + + Args: + source (str): Name of the profile to copy. + target (str): Name of the new profile. It must not exist yet. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Raises: + FileNotFoundError: If *source* has no stored files. + FileExistsError: If *target* already exists. + """ + if profile_origin(target, namespace) != "unknown": + raise FileExistsError(f"Profile '{target}' already exists.") + src = next( + (p for p in runtime_profile_candidates(source, namespace) if os.path.exists(p)), None + ) + if src is None: + src = next( + (p for p in baseline_profile_candidates(source, namespace) if os.path.exists(p)), None + ) + if src is None: + raise FileNotFoundError(f"Profile '{source}' has no stored files.") + created = now_iso_utc() + for dst in (runtime_profile_path(target, namespace), baseline_profile_path(target, namespace)): + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copyfile(src, dst) + settings = QSettings(dst, QSettings.IniFormat) + settings.setValue(SETTINGS_KEYS["created_at"], created) + settings.remove("profile/author") + settings.sync() + + +class TrashedProfile(BaseModel): + """A profile moved to the profile trash; :func:`restore_trashed_profile` puts it back.""" + + token: str + name: str + namespace: str | None = None + deleted_at: str = "" + files: list[tuple[str, str]] = Field(default_factory=list) + + +def _trash_root(namespace: str | None) -> Path: + ns = slugify.slugify(namespace, separator="_") if namespace else "_global" + return Path(_settings_profiles_root()) / TRASH_DIR_NAME / ns + + +def trash_profile(name: str, namespace: str | None = None) -> TrashedProfile | None: + """ + Move every writable file of a profile into the profile trash instead of deleting it. + + Bundled read-only profiles cannot be trashed. The files are moved, not removed, so the + profile can be restored at any time with :func:`restore_trashed_profile`. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + TrashedProfile | None: Description of the trashed profile, or ``None`` when no + writable files were found or the profile is read-only. + """ + if is_profile_read_only(name, namespace): + return None + sources = [ + path + for path in dict.fromkeys( + runtime_profile_candidates(name, namespace) + + baseline_profile_candidates(name, namespace) + ) + if os.path.exists(path) + ] + if not sources: + return None + deleted_at = now_iso_utc() + stamp = deleted_at.replace(":", "").replace("-", "") + token = f"{slugify.slugify(name, separator='_') or 'profile'}__{stamp}" + folder = _trash_root(namespace) / token + suffix = 1 + while folder.exists(): + suffix += 1 + folder = _trash_root(namespace) / f"{token}_{suffix}" + folder.mkdir(parents=True) + files: list[tuple[str, str]] = [] + for index, src in enumerate(sources): + dst = folder / f"{index}_{Path(src).name}" + shutil.move(src, dst) + files.append((str(dst), src)) + entry = TrashedProfile( + token=folder.name, name=name, namespace=namespace, deleted_at=deleted_at, files=files + ) + (folder / "trash.json").write_text(entry.model_dump_json(indent=2), encoding="utf-8") + if get_last_profile(namespace) == name: + set_last_profile(None, namespace) + return entry + + +def list_trashed_profiles(namespace: str | None = None) -> list[TrashedProfile]: + """ + List the profiles in the profile trash, most recently deleted first. + + Args: + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + list[TrashedProfile]: Trashed profiles. + """ + root = _trash_root(namespace) + if not root.is_dir(): + return [] + entries = [] + for meta in root.glob("*/trash.json"): + try: + entries.append(TrashedProfile.model_validate_json(meta.read_text(encoding="utf-8"))) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Skipping unreadable trash entry {meta}: {exc}") + return sorted(entries, key=lambda e: e.deleted_at, reverse=True) + + +def restore_trashed_profile(token: str, namespace: str | None = None) -> str: + """ + Put a trashed profile back where it was. + + Args: + token (str): The ``token`` of the :class:`TrashedProfile`. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + str: The name of the restored profile. + + Raises: + FileNotFoundError: If the trash entry does not exist. + FileExistsError: If a profile with the same name was created in the meantime. + """ + folder = _trash_root(namespace) / token + meta = folder / "trash.json" + if not meta.exists(): + raise FileNotFoundError(f"No trashed profile '{token}'.") + entry = TrashedProfile.model_validate_json(meta.read_text(encoding="utf-8")) + if profile_origin(entry.name, namespace) != "unknown": + raise FileExistsError(f"A profile named '{entry.name}' exists again.") + for trashed, original in entry.files: + os.makedirs(os.path.dirname(original), exist_ok=True) + shutil.move(trashed, original) + # The folder only holds the metadata now; keep it as a record of the restore. + meta.rename(folder / "restored.json") + return entry.name diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/__init__.py b/bec_widgets/widgets/containers/dock_area/profile_ux/__init__.py new file mode 100644 index 00000000..55e0d14b --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/__init__.py @@ -0,0 +1,96 @@ +"""Reworked profile management windows for the dock area, in a QWidget and a QML version. + +``BEC_PROFILE_UI=qwidget`` or ``BEC_PROFILE_UI=qml`` opts into them; the default keeps the +previous dialogs. Both versions share the rules and actions in :mod:`.common`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from bec_widgets.widgets.containers.dock_area.profile_ux.common import ( + POLICY, + UI_ENV, + NameMode, + ProfileActions, + ProfilePolicy, + UiMode, + profile_ui_mode, +) + +if TYPE_CHECKING: # pragma: no cover + from qtpy.QtWidgets import QDialog, QWidget + + from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea + +__all__ = [ + "POLICY", + "UI_ENV", + "ProfileActions", + "ProfilePolicy", + "ask_profile_name", + "confirm_revert", + "create_profile_library", + "profile_ui_mode", +] + + +def _views(ui: UiMode | None): + ui = ui or profile_ui_mode() + if ui == "qml": + from bec_widgets.widgets.containers.dock_area.profile_ux import profile_qml as views + else: + from bec_widgets.widgets.containers.dock_area.profile_ux import profile_qwidget as views + return views + + +def create_profile_library( + dock_area: BECDockArea, parent: QWidget | None = None, ui: UiMode | None = None +) -> QDialog: + """ + Create the non-modal profile library window for *dock_area*. + + Args: + dock_area(BECDockArea): The dock area the library acts on. + parent(QWidget | None): Parent of the window; defaults to the dock area. + ui(UiMode | None): ``qwidget`` or ``qml``; defaults to ``BEC_PROFILE_UI``. + """ + views = _views(ui) + return views.ProfileLibraryDialog(ProfileActions(dock_area), parent or dock_area) + + +def ask_profile_name( + dock_area: BECDockArea, + mode: NameMode, + original: str = "", + parent: QWidget | None = None, + ui: UiMode | None = None, +) -> str | None: + """ + Ask for a name and save, rename or duplicate a profile of *dock_area*. + + Args: + dock_area(BECDockArea): The dock area the request comes from. + mode(NameMode): ``save``, ``rename`` or ``duplicate``. + original(str): The profile to rename or duplicate. + parent(QWidget | None): Dialog parent; defaults to the dock area. + ui(UiMode | None): ``qwidget`` or ``qml``; defaults to ``BEC_PROFILE_UI``. + + Returns: + str | None: The resulting profile name, or None when cancelled. + """ + views = _views(ui) + return views.run_name_flow(ProfileActions(dock_area), mode, original, parent or dock_area) + + +def confirm_revert( + dock_area: BECDockArea, name: str, parent: QWidget | None = None, ui: UiMode | None = None +) -> bool: + """ + Ask whether to revert *name* to its saved layout. + + Returns: + bool: True when the user confirmed; the caller performs the revert. + """ + views = _views(ui) + return views.run_revert_flow(ProfileActions(dock_area), name, parent or dock_area) diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/common.py b/bec_widgets/widgets/containers/dock_area/profile_ux/common.py new file mode 100644 index 00000000..c6ae310a --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/common.py @@ -0,0 +1,942 @@ +"""State, rules and actions shared by the QWidget and QML profile management windows. + +The windows themselves only render what :class:`ProfileLibraryController` and +:func:`check_profile_name` hand them, so both versions behave the same. Everything that +touches profile files goes through :class:`ProfileActions`. + +Two behaviours are still open decisions and live in :data:`POLICY` so either answer works: + +- ``rename_keeps_old_profile``: renaming saves the layout under the new name and keeps the + old profile in the library (current behaviour), or moves the old one to Recently deleted. +- ``one_tab_per_profile``: a profile can be open in only one workspace tab (current + behaviour), or in several. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal + +from bec_lib import bec_logger +from qtpy.QtCore import QDateTime, QObject, Qt, QTimeZone, Signal +from qtpy.QtGui import QPixmap + +from bec_widgets.widgets.containers.dock_area.profile_utils import ( + baseline_profile_candidates, + copy_profile, + get_profile_info, + has_saved_baseline, + is_profile_read_only, + list_profiles, + list_trashed_profiles, + load_baseline_profile_screenshot, + load_profile_screenshot, + open_runtime_settings, + profile_origin, + profile_origin_display, + restore_runtime_from_baseline, + restore_trashed_profile, + set_profile_notes, + set_quick_select, + trash_profile, +) + +if TYPE_CHECKING: # pragma: no cover + from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea + +logger = bec_logger.logger + +UI_ENV = "BEC_PROFILE_UI" +UiMode = Literal["legacy", "qwidget", "qml"] +NameMode = Literal["save", "rename", "duplicate"] +Tone = Literal["neutral", "info", "success", "warning", "error"] + +INVALID_NAME_CHARS = '/\\:*?"<>|' +MAX_NAME_LENGTH = 64 + + +def profile_ui_mode() -> UiMode: + """Return which profile windows to use, from ``BEC_PROFILE_UI`` (default ``legacy``).""" + mode = os.environ.get(UI_ENV, "legacy").strip().lower() + return mode if mode in ("qwidget", "qml") else "legacy" + + +@dataclass +class ProfilePolicy: + """Behaviour switches that are still open decisions; see the module docstring.""" + + rename_keeps_old_profile: bool = True + one_tab_per_profile: bool = True + + +POLICY = ProfilePolicy() + + +################################################################################ +# Small formatting helpers +################################################################################ + + +def relative_time(iso: str, now: QDateTime | None = None) -> str: + """ + Format an ISO 8601 UTC timestamp as a short relative time such as ``"3 h ago"``. + + Args: + iso(str): Timestamp like ``2026-10-09T12:00:00Z``. + now(QDateTime | None): Reference time; defaults to the current UTC time. + + Returns: + str: The relative time, or an empty string for an unreadable timestamp. + """ + stamp = QDateTime.fromString(iso or "", Qt.DateFormat.ISODate) + if not stamp.isValid(): + return "" + now = now or QDateTime.currentDateTimeUtc() + seconds = max(0, stamp.secsTo(now)) + if seconds < 60: + return "just now" + if seconds < 3600: + return f"{seconds // 60} min ago" + if seconds < 86400: + return f"{seconds // 3600} h ago" + days = seconds // 86400 + if days == 1: + return "yesterday" + if days < 14: + return f"{days} days ago" + return stamp.toLocalTime().toString("d MMM yyyy") + + +def absolute_time(iso: str) -> str: + """Format an ISO 8601 UTC timestamp in local time, e.g. ``"9 Oct 2026, 14:02"``.""" + stamp = QDateTime.fromString(iso or "", Qt.DateFormat.ISODate) + if not stamp.isValid(): + return "" + return stamp.toLocalTime().toString("d MMM yyyy, HH:mm") + + +def widget_count_label(count: int) -> str: + """Return ``"1 widget"`` or ``"N widgets"``.""" + return "1 widget" if count == 1 else f"{count} widgets" + + +def unique_name(base: str, taken, suffix: str = "copy") -> str: + """ + Return ``base_``, ``base__2``, ... whichever is free first. + + Args: + base(str): Name to start from. + taken(Callable[[str], bool]): Returns True when a name is in use. + suffix(str): Word appended to the name. + """ + base = (base or "profile").strip() or "profile" + candidate = f"{base}_{suffix}" + counter = 2 + while taken(candidate): + candidate = f"{base}_{suffix}_{counter}" + counter += 1 + return candidate + + +################################################################################ +# Name validation +################################################################################ + + +@dataclass +class NameCheck: + """Result of :func:`check_profile_name`, rendered under the name field. + + Attributes: + ok: Whether the confirm button is enabled. + tone: Colour of the hint line. + message: The hint line. + action: Label of the confirm button. + suggestion: A free name the user can pick with one click, if any. + replaces: Whether confirming replaces an existing user profile. + """ + + ok: bool + tone: Tone + message: str + action: str + suggestion: str = "" + replaces: bool = False + + def as_dict(self) -> dict: + """Return the check as a plain dictionary for QML.""" + return { + "ok": self.ok, + "tone": self.tone, + "message": self.message, + "action": self.action, + "suggestion": self.suggestion, + "replaces": self.replaces, + } + + +_ACTION_LABELS = {"save": "Save", "rename": "Rename", "duplicate": "Duplicate"} + + +def check_profile_name( + text: str, + mode: NameMode, + *, + namespace: str | None, + original: str | None = None, + current: str | None = None, + open_elsewhere=None, +) -> NameCheck: + """ + Validate a profile name while the user types it. + + Args: + text(str): The name as typed. + mode(NameMode): ``save`` (save the current layout), ``rename`` or ``duplicate``. + namespace(str | None): Profile namespace. + original(str | None): The profile being renamed or duplicated. + current(str | None): The profile shown in the dock area the request comes from. + open_elsewhere(Callable[[str], bool] | None): Whether a profile is open in another tab. + + Returns: + NameCheck: What to show and whether the name can be used. + """ + name = (text or "").strip() + action = _ACTION_LABELS[mode] + + def taken(candidate: str) -> bool: + return profile_origin(candidate, namespace) != "unknown" + + if not name: + return NameCheck(False, "neutral", "Enter a name for the profile.", action) + bad = sorted({c for c in name if c in INVALID_NAME_CHARS}) + if bad or name.startswith("."): + shown = " ".join(bad) if bad else "a leading dot" + return NameCheck(False, "error", f"Names cannot contain {shown}.", action) + if len(name) > MAX_NAME_LENGTH: + return NameCheck( + False, "error", f"Use at most {MAX_NAME_LENGTH} characters ({len(name)} now).", action + ) + if mode == "rename" and original and name == original: + return NameCheck(False, "neutral", "This is the current name.", action) + + origin = profile_origin(name, namespace) + if origin in ("module", "plugin"): + provider = profile_origin_display(name, namespace) or "BEC" + return NameCheck( + False, + "error", + f"'{name}' is a read-only profile from {provider}. Pick another name.", + action, + suggestion=unique_name(name, taken, "custom"), + ) + if ( + POLICY.one_tab_per_profile + and open_elsewhere is not None + and name != current + and name != original + and open_elsewhere(name) + ): + return NameCheck( + False, + "error", + f"'{name}' is open in another tab. Close that tab or pick another name.", + action, + suggestion=unique_name(name, taken), + ) + if mode == "save" and current and name == current: + return NameCheck(True, "info", f"Updates the saved layout of '{name}'.", "Save") + if origin == "settings": + info = get_profile_info(name, namespace) + edited = relative_time(info.modified) + detail = f" (edited {edited})" if edited else "" + return NameCheck( + True, + "warning", + f"Replaces the saved profile '{name}'{detail}. " + "The old version goes to Recently deleted.", + "Replace", + replaces=True, + ) + hints = { + "save": "Saves the current layout as a new profile.", + "rename": "", + "duplicate": f"Creates a copy of '{original}'." if original else "Creates a copy.", + } + if mode == "rename": + if original and POLICY.rename_keeps_old_profile: + hints["rename"] = f"Saves the layout as '{name}'. '{original}' stays in the library." + elif original: + hints["rename"] = ( + f"Renames '{original}' to '{name}'. The old name goes to Recently deleted." + ) + else: + hints["rename"] = f"Saves this workspace as '{name}'." + return NameCheck(True, "neutral", hints[mode], action) + + +################################################################################ +# Actions +################################################################################ + + +class ProfileActions: + """ + Profile operations used by the reworked windows, working on one dock area and, when the + dock area lives in workspace tabs, on all tabs. + + Nothing is removed from disk: replaced and deleted profiles are moved to the profile + trash (Recently deleted), from where they can be restored. + + Args: + dock_area(BECDockArea): The dock area the windows were opened from. + """ + + def __init__(self, dock_area: BECDockArea): + self._origin_dock_area = dock_area + + # -- context ----------------------------------------------------------------- + + @property + def host(self): + """The :class:`WorkspaceTabs` hosting the dock area, or None.""" + return getattr(self._origin_dock_area, "workspace_host", None) + + @property + def namespace(self) -> str | None: + """Profile namespace.""" + return self._origin_dock_area.profile_namespace + + def dock_area(self) -> BECDockArea: + """The dock area actions apply to: the current tab when hosted in tabs.""" + host = self.host + if host is not None: + current = host.current_dock_area() + if current is not None: + return current + return self._origin_dock_area + + def current_profile(self) -> str | None: + """Profile shown in :meth:`dock_area`.""" + return getattr(self.dock_area(), "_current_profile_name", None) + + def open_profiles(self) -> list[str]: + """Profiles that are open in a tab (or in this dock area when not in tabs).""" + host = self.host + if host is not None: + return [name for name in host.workspace_names() if name] + current = self.current_profile() + return [current] if current else [] + + def open_elsewhere(self, name: str) -> bool: + """Whether *name* is open in a tab other than the current one.""" + return name != self.current_profile() and name in self.open_profiles() + + def taken(self, name: str) -> bool: + """Whether a profile with *name* exists.""" + return profile_origin(name, self.namespace) != "unknown" + + def check(self, text: str, mode: NameMode, original: str | None = None) -> NameCheck: + """Validate *text* for *mode*; see :func:`check_profile_name`.""" + return check_profile_name( + text, + mode, + namespace=self.namespace, + original=original, + current=self.current_profile(), + open_elsewhere=self.open_elsewhere, + ) + + def _dock_area_showing(self, name: str): + host = self.host + if host is not None: + for dock_area in host.dock_areas(): + if dock_area._current_profile_name == name: # pylint: disable=protected-access + return dock_area + return None + dock_area = self._origin_dock_area + shown = dock_area._current_profile_name # pylint: disable=protected-access + return dock_area if shown == name else None + + def _refresh_toolbars(self) -> None: + host = self.host + dock_areas = host.dock_areas() if host is not None else [self._origin_dock_area] + for dock_area in dock_areas: + dock_area._refresh_workspace_list() # pylint: disable=protected-access + + def _flush(self, name: str) -> None: + """Write the live layout of an open profile to its working copy.""" + dock_area = self._dock_area_showing(name) + if dock_area is not None: + settings = open_runtime_settings(name, namespace=self.namespace) + dock_area._write_snapshot_to_settings(settings) # pylint: disable=protected-access + + def _trash_for_replace(self, name: str) -> bool: + if self.taken(name) and not is_profile_read_only(name, self.namespace): + return trash_profile(name, self.namespace) is not None + return False + + # -- operations -------------------------------------------------------------- + + def save_current( + self, name: str, *, notes: str | None = None, pinned: bool | None = None + ) -> str: + """ + Save the layout of :meth:`dock_area` under *name*. + + Returns: + str: A one-line summary for the confirmation banner. + """ + dock_area = self.dock_area() + current = self.current_profile() + replaced = name != current and self._trash_for_replace(name) + dock_area.save_profile(name, show_dialog=False, quick_select=pinned) + if notes is not None: + set_profile_notes(name, notes, self.namespace) + if replaced: + return f"Saved '{name}'. The version it replaced is in Recently deleted." + return f"Saved '{name}'." + + def open(self, name: str, *, new_tab: bool = True) -> None: + """Open *name* in a new tab (switching to it when open) or in the current tab.""" + host = self.host + if host is not None and (new_tab or self.open_elsewhere(name)): + host.open_workspace(name) + return + self.dock_area().load_profile(name) + + def set_pinned(self, name: str, pinned: bool) -> None: + """Show or hide *name* in the toolbar profile list.""" + set_quick_select(name, pinned, namespace=self.namespace) + self._refresh_toolbars() + + def set_notes(self, name: str, notes: str) -> None: + """Store the description of *name*.""" + set_profile_notes(name, notes, self.namespace) + + def duplicate(self, name: str, new_name: str) -> str: + """Copy *name* to *new_name*; returns the banner text.""" + self._flush(name) + replaced = self._trash_for_replace(new_name) + copy_profile(name, new_name, self.namespace) + suffix = " The version it replaced is in Recently deleted." if replaced else "" + return f"Created '{new_name}' from '{name}'.{suffix}" + + def rename(self, name: str | None, new_name: str) -> str: + """ + Rename *name* to *new_name* following :data:`POLICY`; *name* None names the + unsaved workspace of the current tab. Returns the banner text. + """ + if name is None: + return self.save_current(new_name) + replaced = self._trash_for_replace(new_name) + dock_area = self._dock_area_showing(name) + host = self.host + if dock_area is not None and host is not None: + index = host.find_workspace(name) + host.rename_workspace(index, name=new_name, confirm=False) + elif dock_area is not None: + dock_area.save_profile(new_name, show_dialog=False) + else: + copy_profile(name, new_name, self.namespace) + moved_old = False + if not POLICY.rename_keeps_old_profile and not is_profile_read_only(name, self.namespace): + moved_old = trash_profile(name, self.namespace) is not None + self._refresh_toolbars() + parts = [f"Renamed '{name}' to '{new_name}'."] + if moved_old: + parts.append(f"'{name}' is in Recently deleted.") + elif POLICY.rename_keeps_old_profile: + parts.append(f"'{name}' is still in the library.") + if replaced: + parts.append("The profile it replaced is in Recently deleted.") + return " ".join(parts) + + def can_delete(self, name: str) -> tuple[bool, str]: + """Whether *name* can be moved to Recently deleted, and why not.""" + if is_profile_read_only(name, self.namespace): + return False, "Read-only profiles cannot be deleted. Duplicate it to make changes." + if name in self.open_profiles(): + return False, "Open in a tab. Close the tab to delete the profile." + return True, "" + + def delete(self, name: str): + """Move *name* to Recently deleted; returns the trash entry or None.""" + ok, reason = self.can_delete(name) + if not ok: + raise ValueError(reason) + entry = trash_profile(name, self.namespace) + self._refresh_toolbars() + return entry + + def restore(self, token: str) -> str: + """Put a trashed profile back; returns its name.""" + name = restore_trashed_profile(token, self.namespace) + self._refresh_toolbars() + return name + + def can_revert(self, name: str) -> bool: + """Whether *name* has a saved copy to revert to.""" + return has_saved_baseline(name, self.namespace) + + def revert(self, name: str) -> None: + """Replace the working copy of *name* with its saved copy (and reload it if open).""" + dock_area = self._dock_area_showing(name) + if dock_area is not None: + dock_area.restore_baseline_profile(name, show_dialog=False) + else: + restore_runtime_from_baseline(name, namespace=self.namespace) + + def name_form(self, mode: NameMode, original: str = "") -> dict: + """Initial values of the name dialog: ``initial`` text, ``notes`` and ``pinned``.""" + if mode == "save": + current = self.current_profile() + info = get_profile_info(current, self.namespace) if current else None + return { + "initial": current or "", + "notes": info.notes if info else "", + "pinned": info.is_quick_select if info else True, + } + initial = unique_name(original, self.taken) if mode == "duplicate" else original + return {"initial": initial, "notes": "", "pinned": True} + + def apply_name( + self, mode: NameMode, original: str, name: str, notes: str = "", pinned: bool = True + ) -> str: + """Run a confirmed save, rename or duplicate; returns the banner text.""" + name = name.strip() + if mode == "save": + return self.save_current(name, notes=notes, pinned=pinned) + if mode == "rename": + return self.rename(original or None, name) + return self.duplicate(original, name) + + def saved_at(self, name: str) -> str: + """ISO timestamp of the saved copy of *name*, or an empty string.""" + for path in baseline_profile_candidates(name, self.namespace): + if os.path.exists(path): + stamp = QDateTime.fromSecsSinceEpoch(int(os.path.getmtime(path)), QTimeZone.utc()) + return stamp.toString("yyyy-MM-ddTHH:mm:ssZ") + return "" + + # -- previews ---------------------------------------------------------------- + + def live_preview(self, name: str | None = None) -> QPixmap | None: + """Screenshot of the dock area showing *name* (the current one by default).""" + dock_area = self.dock_area() if name is None else self._dock_area_showing(name) + if dock_area is None or not dock_area.isVisible(): + return None + pixmap = QPixmap() + data = dock_area.screenshot_bytes() + if not data or not pixmap.loadFromData(bytes(data)): + return None + return pixmap + + def preview(self, name: str) -> QPixmap | None: + """Best preview of *name*: live when it is shown in the current tab, else stored.""" + if name == self.current_profile(): + live = self.live_preview() + if live is not None: + return live + return load_profile_screenshot(name, namespace=self.namespace) + + def saved_preview(self, name: str) -> QPixmap | None: + """Stored screenshot of the saved copy of *name*.""" + return load_baseline_profile_screenshot(name, namespace=self.namespace) + + +################################################################################ +# Library state +################################################################################ + +SECTION_MINE = "My profiles" +SECTION_BUNDLED = "Read-only profiles" +SECTION_TRASH = "Recently deleted" + +ROW_KEYS = [ + "key", + "kind", + "section", + "name", + "subtitle", + "readOnly", + "pinned", + "isOpen", + "isCurrent", + "selected", + "token", +] + + +@dataclass +class Banner: + """A one-line confirmation shown at the top of the library, optionally with an action.""" + + tone: Tone + text: str + action: str = "" + action_id: str = "" + + def as_dict(self) -> dict: + """Return the banner as a plain dictionary for QML.""" + return {"tone": self.tone, "text": self.text, "action": self.action, "id": self.action_id} + + +@dataclass +class LibraryState: + """Everything the library views render.""" + + rows: list[dict] = field(default_factory=list) + selected: dict | None = None + query: str = "" + banner: Banner | None = None + pending_delete: str = "" + empty_text: str = "" + has_tabs: bool = False + total: int = 0 + + +class ProfileLibraryController(QObject): + """ + View state of the profile library: rows with sections, the selected profile, the search + text, inline delete confirmation and the undo banner. + + Args: + actions(ProfileActions): The profile operations. + parent(QObject | None): Parent object. + """ + + changed = Signal() + preview_changed = Signal() + # Ask the view to show the name dialog: mode, original name + name_requested = Signal(str, str) + # Ask the view to confirm a revert: profile name + revert_requested = Signal(str) + + def __init__(self, actions: ProfileActions, parent: QObject | None = None): + super().__init__(parent) + self.actions = actions + self.state = LibraryState(has_tabs=actions.host is not None) + self._selected_key = "" + self._entries: dict[str, dict] = {} + self.preview_revision = 0 + self.refresh(select=actions.current_profile()) + + # -- building ---------------------------------------------------------------- + + def refresh(self, select: str | None = None) -> None: + """Re-read the profiles from disk and rebuild the rows.""" + namespace = self.actions.namespace + open_names = set(self.actions.open_profiles()) + current = self.actions.current_profile() + entries: dict[str, dict] = {} + for name in list_profiles(namespace): + info = get_profile_info(name, namespace) + if name == current: + info.widget_count = len(self.actions.dock_area().dock_list()) + edited = relative_time(info.modified) + subtitle = [widget_count_label(info.widget_count)] + if edited: + subtitle.append(f"edited {edited}") + entries[f"p:{name}"] = { + "key": f"p:{name}", + "kind": "bundled" if info.is_read_only else "user", + "section": SECTION_BUNDLED if info.is_read_only else SECTION_MINE, + "name": name, + "subtitle": " · ".join(subtitle), + "readOnly": info.is_read_only, + "pinned": info.is_quick_select, + "isOpen": name in open_names, + "isCurrent": name == current, + "token": "", + "info": info, + } + for entry in list_trashed_profiles(namespace): + deleted = relative_time(entry.deleted_at) + entries[f"t:{entry.token}"] = { + "key": f"t:{entry.token}", + "kind": "trash", + "section": SECTION_TRASH, + "name": entry.name, + "subtitle": f"deleted {deleted}" if deleted else "deleted", + "readOnly": False, + "pinned": False, + "isOpen": False, + "isCurrent": False, + "token": entry.token, + "info": entry, + } + self._entries = entries + if select: + self._selected_key = f"p:{select}" + if self._selected_key not in entries: + self._selected_key = next( + (k for k, e in entries.items() if e["kind"] != "trash"), next(iter(entries), "") + ) + self.preview_revision += 1 + self._rebuild() + + def _matches(self, entry: dict) -> bool: + query = self.state.query.strip().lower() + if not query: + return True + info = entry["info"] + haystack = f"{entry['name']} {getattr(info, 'notes', '')}".lower() + return all(part in haystack for part in query.split()) + + def _rebuild(self) -> None: + order = {SECTION_MINE: 0, SECTION_BUNDLED: 1, SECTION_TRASH: 2} + visible = [e for e in self._entries.values() if self._matches(e)] + visible.sort(key=lambda e: (order[e["section"]], e["name"].lower())) + rows = [] + for entry in visible: + row = {key: entry.get(key) for key in ROW_KEYS} + row["selected"] = entry["key"] == self._selected_key + rows.append(row) + self.state.rows = rows + self.state.total = sum(1 for e in self._entries.values() if e["kind"] != "trash") + if not rows: + self.state.empty_text = ( + f"No profile matches '{self.state.query.strip()}'." + if self.state.query.strip() + else "No saved profiles yet. Save the current layout to create one." + ) + else: + self.state.empty_text = "" + self.state.selected = self._details(self._entries.get(self._selected_key)) + if self.state.pending_delete and ( + not self.state.selected or self.state.selected["name"] != self.state.pending_delete + ): + self.state.pending_delete = "" + self.changed.emit() + + def _details(self, entry: dict | None) -> dict | None: + if entry is None: + return None + if entry["kind"] == "trash": + trashed = entry["info"] + return { + "key": entry["key"], + "kind": "trash", + "name": entry["name"], + "token": entry["token"], + "facts": [("Deleted", absolute_time(trashed.deleted_at))], + "notes": "", + "status": "In Recently deleted. Restore it to use it again.", + "readOnly": False, + "pinned": False, + "isOpen": False, + "isCurrent": False, + "canRevert": False, + "canDelete": False, + "deleteReason": "", + "source": "", + } + info = entry["info"] + name = entry["name"] + can_delete, reason = self.actions.can_delete(name) + source = profile_origin_display(name, self.actions.namespace) or "" + if entry["isCurrent"]: + status = "Shown in this tab" if self.state.has_tabs else "Currently loaded" + elif entry["isOpen"]: + status = "Open in another tab" + else: + status = "" + facts = [ + ("Widgets", str(info.widget_count)), + ("Edited", absolute_time(info.modified)), + ("Created", absolute_time(info.created)), + ("Source", f"{source} (read-only)" if info.is_read_only else "Saved by you"), + ] + return { + "key": entry["key"], + "kind": entry["kind"], + "name": name, + "token": "", + "facts": facts, + "notes": info.notes, + "status": status, + "readOnly": info.is_read_only, + "pinned": info.is_quick_select, + "isOpen": entry["isOpen"], + "isCurrent": entry["isCurrent"], + "canRevert": self.actions.can_revert(name), + "canDelete": can_delete, + "deleteReason": reason, + "source": source, + } + + # -- view requests ----------------------------------------------------------- + + def selected_name(self) -> str | None: + """Name of the selected profile (None for nothing or a trashed one).""" + selected = self.state.selected + if not selected or selected["kind"] == "trash": + return None + return selected["name"] + + def preview_for_selected(self) -> QPixmap | None: + """Preview of the selected profile.""" + name = self.selected_name() + return self.actions.preview(name) if name else None + + def select(self, key: str) -> None: + """Select the row with *key*.""" + if key in self._entries and key != self._selected_key: + self._selected_key = key + self.state.pending_delete = "" + self.preview_revision += 1 + self._rebuild() + self.preview_changed.emit() + + def move_selection(self, step: int) -> None: + """Select the next (step > 0) or previous visible row.""" + keys = [row["key"] for row in self.state.rows] + if not keys: + return + index = keys.index(self._selected_key) if self._selected_key in keys else -1 + self.select(keys[max(0, min(len(keys) - 1, index + step))]) + + def set_query(self, query: str) -> None: + """Filter the rows by name and description.""" + self.state.query = query + self._rebuild() + keys = [row["key"] for row in self.state.rows] + if keys and self._selected_key not in keys: + self.select(keys[0]) + + def dismiss_banner(self) -> None: + """Hide the banner.""" + self.state.banner = None + self.changed.emit() + + def _done(self, text: str, select: str | None = None, **banner) -> None: + self.state.banner = Banner(banner.pop("tone", "success"), text, **banner) + self.refresh(select=select) + self.preview_changed.emit() + + def _fail(self, exc: Exception) -> None: + logger.warning(f"Profile library: {exc}") + self.state.banner = Banner("error", str(exc)) + self.changed.emit() + + def open_selected(self, new_tab: bool = True) -> None: + """Open the selected profile in a new tab (or the current one).""" + name = self.selected_name() + if not name: + return + try: + self.actions.open(name, new_tab=new_tab) + except Exception as exc: # pylint: disable=broad-except + self._fail(exc) + return + where = "in a new tab" if new_tab and self.state.has_tabs else "here" + self._done(f"Opened '{name}' {where}.", select=name, tone="info") + + def toggle_pin(self, key: str | None = None) -> None: + """Toggle whether a profile shows in the toolbar list.""" + entry = self._entries.get(key or self._selected_key) + if not entry or entry["kind"] == "trash": + return + pinned = not entry["pinned"] + self.actions.set_pinned(entry["name"], pinned) + state = "now shows" if pinned else "no longer shows" + self._done(f"'{entry['name']}' {state} in the toolbar list.", tone="info") + + def save_notes(self, notes: str) -> None: + """Store the description of the selected profile.""" + name = self.selected_name() + selected = self.state.selected + if not name or selected is None or (selected["notes"] or "") == (notes or "").strip(): + return + self.actions.set_notes(name, notes) + self.refresh() + + def request_name(self, mode: NameMode) -> None: + """Ask the view for a name: ``save`` (current layout), ``rename`` or ``duplicate``.""" + original = "" if mode == "save" else (self.selected_name() or "") + if mode != "save" and not original: + return + self.name_requested.emit(mode, original) + + def apply_name(self, mode: NameMode, original: str, name: str, **options) -> bool: + """Run the confirmed save, rename or duplicate.""" + name = name.strip() + try: + text = self.actions.apply_name(mode, original, name, **options) + except Exception as exc: # pylint: disable=broad-except + self._fail(exc) + return False + self._done(text, select=name) + return True + + def request_revert(self) -> None: + """Ask the view to confirm reverting the selected profile.""" + name = self.selected_name() + if name and self.actions.can_revert(name): + self.revert_requested.emit(name) + + def apply_revert(self, name: str) -> None: + """Revert *name* to its saved copy.""" + try: + self.actions.revert(name) + except Exception as exc: # pylint: disable=broad-except + self._fail(exc) + return + self._done(f"Reverted '{name}' to its saved layout.", select=name) + + def request_delete(self) -> None: + """Show the inline delete confirmation for the selected profile.""" + name = self.selected_name() + if name and self.actions.can_delete(name)[0]: + self.state.pending_delete = name + self.changed.emit() + + def cancel_delete(self) -> None: + """Hide the inline delete confirmation.""" + self.state.pending_delete = "" + self.changed.emit() + + def confirm_delete(self) -> None: + """Move the profile awaiting confirmation to Recently deleted.""" + name = self.state.pending_delete + self.state.pending_delete = "" + if not name: + return + try: + entry = self.actions.delete(name) + except Exception as exc: # pylint: disable=broad-except + self._fail(exc) + return + if entry is None: + self._fail(ValueError(f"No files of '{name}' were found.")) + return + self._done( + f"Moved '{name}' to Recently deleted.", + action="Undo", + action_id=f"restore:{entry.token}", + ) + + def restore(self, token: str | None = None) -> None: + """Restore a trashed profile (the selected one by default).""" + if token is None: + selected = self.state.selected + token = selected["token"] if selected and selected["kind"] == "trash" else "" + if not token: + return + try: + name = self.actions.restore(token) + except Exception as exc: # pylint: disable=broad-except + self._fail(exc) + return + self._done(f"Restored '{name}'.", select=name) + + def banner_action(self) -> None: + """Run the action of the banner (e.g. Undo).""" + banner = self.state.banner + if banner is None or not banner.action_id: + return + kind, _, value = banner.action_id.partition(":") + if kind == "restore": + self.restore(value) diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/profile_qml.py b/bec_widgets/widgets/containers/dock_area/profile_ux/profile_qml.py new file mode 100644 index 00000000..b2560285 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/profile_qml.py @@ -0,0 +1,449 @@ +"""Profile library, name dialog and revert dialog rendered with Qt Quick (QML). + +Same UX as :mod:`profile_qwidget`; state and rules come from :mod:`.common`. +""" + +# QML properties below read the backend's private state through lambdas. +# pylint: disable=protected-access + +from __future__ import annotations + +from pathlib import Path + +from qtpy.QtCore import Property, QObject, QSize, Qt, Signal, Slot +from qtpy.QtGui import QPixmap +from qtpy.QtQuick import QQuickImageProvider +from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget + +from bec_widgets.utils.quick import ( + DictListModel, + create_quick_widget, + quick_engine, + release_quick_widget, +) +from bec_widgets.widgets.containers.dock_area.profile_utils import is_profile_read_only +from bec_widgets.widgets.containers.dock_area.profile_ux.common import ( + ROW_KEYS, + NameMode, + ProfileActions, + ProfileLibraryController, + absolute_time, + relative_time, +) + +QML_DIR = Path(__file__).parent / "qml" +PROVIDER_ID = "bwprofile" +TITLES = {"save": "Save profile", "rename": "Rename profile", "duplicate": "Duplicate profile"} + + +class PreviewProvider(QQuickImageProvider): + """Serve profile screenshots to QML as ``image://bwprofile/?``.""" + + def __init__(self): + super().__init__(QQuickImageProvider.ImageType.Pixmap) + self._pixmaps: dict[str, QPixmap] = {} + self._revision = 0 + + def publish(self, key: str, pixmap: QPixmap | None) -> str: + """Store *pixmap* under *key* and return its URL, or "" when there is none.""" + if pixmap is None or pixmap.isNull(): + self._pixmaps.pop(key, None) + return "" + self._revision += 1 + self._pixmaps[key] = pixmap + return f"image://{PROVIDER_ID}/{key}?{self._revision}" + + def requestPixmap( + self, image_id: str, size: QSize, requested: QSize + ) -> QPixmap: # pylint: disable=invalid-name + pixmap = self._pixmaps.get(image_id.partition("?")[0], QPixmap()) + if requested.isValid() and not pixmap.isNull() and requested.width() > 0: + pixmap = pixmap.scaled( + requested, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + if size is not None: + size.setWidth(pixmap.width()) + size.setHeight(pixmap.height()) + return pixmap + + +_PROVIDER: PreviewProvider | None = None + + +def preview_provider() -> PreviewProvider: + """Return the preview image provider, registering it with the shared QML engine.""" + global _PROVIDER # pylint: disable=global-statement + engine = quick_engine() + if _PROVIDER is None or engine.imageProvider(PROVIDER_ID) is None: + _PROVIDER = PreviewProvider() + engine.addImageProvider(PROVIDER_ID, _PROVIDER) + return _PROVIDER + + +class _QuickDialog(QDialog): + """QDialog showing one QML file with a backend object.""" + + def __init__(self, qml_file: str, backend: QObject, parent: QWidget | None, title: str): + super().__init__(parent) + self.setWindowTitle(title) + self.backend = backend + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.view = create_quick_widget(self, QML_DIR / qml_file, {"backend": backend}) + self.view.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + layout.addWidget(self.view) + self.view.setFocus() + + def done(self, result: int) -> None: # pylint: disable=invalid-name + """Unload the scene before the backend goes away.""" + release_quick_widget(self.view) + super().done(result) + + +################################################################################ +# Name dialog +################################################################################ + + +class NameBackend(QObject): + """Backend of ``ProfileNameDialog.qml``.""" + + changed = Signal() + accepted = Signal() + rejected = Signal() + + def __init__( + self, + actions: ProfileActions, + mode: NameMode, + original: str, + form: dict, + preview: QPixmap | None, + parent: QObject | None = None, + ): + super().__init__(parent) + self.actions = actions + self._mode = mode + self._original = original + self._name = form["initial"] + self._notes = form["notes"] + self._pinned = form["pinned"] + self._preview = preview_provider().publish(f"name-{id(self)}", preview) + self._check = actions.check(self._name, mode, original or None).as_dict() + + # pylint: disable=invalid-name,missing-function-docstring + @Slot(str) + def setName(self, text: str) -> None: + self._name = text + self._check = self.actions.check(text, self._mode, self._original or None).as_dict() + self.changed.emit() + + @Slot(str) + def setNotes(self, text: str) -> None: + self._notes = text + + @Slot(bool) + def setPinned(self, value: bool) -> None: + self._pinned = value + + @Slot() + def confirm(self) -> None: + if self._check["ok"]: + self.accepted.emit() + + @Slot() + def cancel(self) -> None: + self.rejected.emit() + + def name(self) -> str: + return self._name.strip() + + def notes(self) -> str: + return self._notes.strip() + + def pinned(self) -> bool: + return bool(self._pinned) + + mode = Property(str, lambda self: self._mode, constant=True) + heading = Property( + str, + lambda self: ( + TITLES[self._mode] + if self._mode == "save" + else f"{TITLES[self._mode]} '{self._original}'" + ), + constant=True, + ) + initialName = Property(str, lambda self: self._name, constant=True) + initialNotes = Property(str, lambda self: self._notes, constant=True) + initialPinned = Property(bool, lambda self: bool(self._pinned), constant=True) + previewUrl = Property(str, lambda self: self._preview, constant=True) + check = Property("QVariantMap", lambda self: self._check, notify=changed) + + +class ProfileNameDialog(_QuickDialog): + """QML version of the save, rename and duplicate dialog.""" + + def __init__( + self, + actions: ProfileActions, + mode: NameMode, + original: str = "", + form: dict | None = None, + preview: QPixmap | None = None, + parent: QWidget | None = None, + ): + form = form or actions.name_form(mode, original) + backend = NameBackend(actions, mode, original, form, preview) + super().__init__("ProfileNameDialog.qml", backend, parent, TITLES[mode]) + backend.setParent(self) + self.setModal(True) + self.resize(620, 380 if mode == "save" else 270) + backend.accepted.connect(self.accept) + backend.rejected.connect(self.reject) + + def profile_name(self) -> str: + """The entered name.""" + return self.backend.name() + + def notes(self) -> str: + """The entered description.""" + return self.backend.notes() + + def pinned(self) -> bool: + """Whether the profile shows in the toolbar list.""" + return self.backend.pinned() + + +################################################################################ +# Revert dialog +################################################################################ + + +class RevertBackend(QObject): + """Backend of ``RevertProfileDialog.qml``.""" + + accepted = Signal() + rejected = Signal() + + def __init__(self, name: str, saved_at: str, current, saved, read_only: bool, parent=None): + super().__init__(parent) + provider = preview_provider() + self._name = name + when = relative_time(saved_at) + what = "its original layout" if read_only else "the layout saved" + text = f"Changes made since then are discarded. You go back to {what}" + self._text = text + (f" {when}." if when and not read_only else ".") + self._saved_caption = absolute_time(saved_at) or "Saved layout" + self._now = provider.publish(f"revert-now-{id(self)}", current) + self._saved = provider.publish(f"revert-saved-{id(self)}", saved) + + # pylint: disable=invalid-name,missing-function-docstring + @Slot() + def confirm(self) -> None: + self.accepted.emit() + + @Slot() + def cancel(self) -> None: + self.rejected.emit() + + name = Property(str, lambda self: self._name, constant=True) + text = Property(str, lambda self: self._text, constant=True) + savedCaption = Property(str, lambda self: self._saved_caption, constant=True) + nowUrl = Property(str, lambda self: self._now, constant=True) + savedUrl = Property(str, lambda self: self._saved, constant=True) + + +class RevertProfileDialog(_QuickDialog): + """QML version of the revert confirmation.""" + + def __init__(self, name, saved_at, current, saved, read_only=False, parent=None): + backend = RevertBackend(name, saved_at, current, saved, read_only) + super().__init__("RevertProfileDialog.qml", backend, parent, "Revert to saved layout") + backend.setParent(self) + self.setModal(True) + self.resize(720, 400) + backend.accepted.connect(self.accept) + backend.rejected.connect(self.reject) + + +################################################################################ +# Library +################################################################################ + + +class LibraryBackend(QObject): + """Bridge between :class:`ProfileLibraryController` and ``ProfileLibrary.qml``.""" + + changed = Signal() + previewChanged = Signal() + + def __init__(self, controller: ProfileLibraryController, parent: QObject | None = None): + super().__init__(parent) + self.controller = controller + self._rows = DictListModel(ROW_KEYS, self) + self._state: dict = {} + self._preview = "" + controller.changed.connect(self._sync) + controller.preview_changed.connect(self._sync_preview) + self._sync() + self._sync_preview() + + def _sync(self) -> None: + state = self.controller.state + self._rows.set_items(state.rows) + selected = dict(state.selected or {}) + if selected: + selected["facts"] = [{"label": k, "value": v} for k, v in selected["facts"]] + self._state = { + "selected": selected, + "query": state.query, + "banner": state.banner.as_dict() if state.banner else {}, + "pendingDelete": state.pending_delete, + "emptyText": state.empty_text, + "hasTabs": state.has_tabs, + "total": state.total, + } + self.changed.emit() + + def _sync_preview(self) -> None: + self._preview = preview_provider().publish( + f"library-{id(self)}", self.controller.preview_for_selected() + ) + self.previewChanged.emit() + + # pylint: disable=invalid-name,missing-function-docstring + @Slot(str) + def select(self, key: str) -> None: + self.controller.select(key) + + @Slot(int) + def moveSelection(self, step: int) -> None: + self.controller.move_selection(step) + + @Slot(str) + def setQuery(self, text: str) -> None: + self.controller.set_query(text) + + @Slot(bool) + def openSelected(self, new_tab: bool) -> None: + self.controller.open_selected(new_tab) + + @Slot(str) + def togglePin(self, key: str) -> None: + self.controller.toggle_pin(key) + + @Slot(str) + def saveNotes(self, text: str) -> None: + self.controller.save_notes(text) + + @Slot(str) + def requestName(self, mode: str) -> None: + self.controller.request_name(mode) + + @Slot() + def requestRevert(self) -> None: + self.controller.request_revert() + + @Slot() + def requestDelete(self) -> None: + self.controller.request_delete() + + @Slot() + def cancelDelete(self) -> None: + self.controller.cancel_delete() + + @Slot() + def confirmDelete(self) -> None: + self.controller.confirm_delete() + + @Slot(str) + def restore(self, token: str) -> None: + self.controller.restore(token or None) + + @Slot() + def bannerAction(self) -> None: + self.controller.banner_action() + + @Slot() + def dismissBanner(self) -> None: + self.controller.dismiss_banner() + + rows = Property(QObject, lambda self: self._rows, constant=True) + state = Property("QVariantMap", lambda self: self._state, notify=changed) + previewUrl = Property(str, lambda self: self._preview, notify=previewChanged) + + +class ProfileLibraryDialog(QDialog): + """Non-modal window hosting ``ProfileLibrary.qml``.""" + + def __init__(self, actions: ProfileActions, parent: QWidget | None = None): + super().__init__(parent) + self.setWindowTitle("Profile library") + self.setModal(False) + self.resize(1040, 640) + self.setMinimumSize(820, 520) + self.controller = ProfileLibraryController(actions, self) + self.backend = LibraryBackend(self.controller, self) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.view = create_quick_widget( + self, QML_DIR / "ProfileLibrary.qml", {"backend": self.backend} + ) + self.view.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + layout.addWidget(self.view) + self.view.setFocus() + self.controller.name_requested.connect(self._ask_name) + self.controller.revert_requested.connect(self._ask_revert) + + def done(self, result: int) -> None: # pylint: disable=invalid-name + """Unload the scene before the backend goes away.""" + release_quick_widget(self.view) + super().done(result) + + def _ask_name(self, mode: str, original: str) -> None: + actions = self.controller.actions + preview = actions.live_preview() if mode == "save" else actions.preview(original) + dialog = ProfileNameDialog(actions, mode, original, None, preview, self) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + self.controller.apply_name( + mode, original, dialog.profile_name(), notes=dialog.notes(), pinned=dialog.pinned() + ) + + def _ask_revert(self, name: str) -> None: + if run_revert_flow(self.controller.actions, name, self): + self.controller.apply_revert(name) + + +def run_name_flow( + actions: ProfileActions, mode: NameMode, original: str = "", parent: QWidget | None = None +) -> str | None: + """ + Show the QML name dialog and run the save, rename or duplicate. + + Returns: + str | None: The new name, or None when cancelled. + """ + preview = actions.preview(original) if mode == "duplicate" else actions.live_preview() + dialog = ProfileNameDialog(actions, mode, original, None, preview, parent) + if dialog.exec() != QDialog.DialogCode.Accepted: + return None + actions.apply_name(mode, original, dialog.profile_name(), dialog.notes(), dialog.pinned()) + return dialog.profile_name() + + +def run_revert_flow(actions: ProfileActions, name: str, parent: QWidget | None = None) -> bool: + """Ask whether to revert *name*; returns True when confirmed (the caller reverts).""" + current = actions.live_preview(name) or actions.preview(name) + dialog = RevertProfileDialog( + name, + actions.saved_at(name), + current, + actions.saved_preview(name), + is_profile_read_only(name, actions.namespace), + parent, + ) + return dialog.exec() == QDialog.DialogCode.Accepted diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/profile_qwidget.py b/bec_widgets/widgets/containers/dock_area/profile_ux/profile_qwidget.py new file mode 100644 index 00000000..7f24f6b8 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/profile_qwidget.py @@ -0,0 +1,940 @@ +"""Profile library, name dialog and revert dialog built from QWidgets. + +Same UX as :mod:`profile_qml`; state and rules come from :mod:`.common`. +""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import QRectF, QSize, Qt, QTimer, Signal +from qtpy.QtGui import QColor, QKeySequence, QPainter, QPainterPath, QPixmap, QShortcut +from qtpy.QtWidgets import ( + QDialog, + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.quick.host import ThemeTokens +from bec_widgets.utils.ux_kit import ( + IconButton, + StatusPill, + TextButton, + ToggleSwitch, + field_qss, + rgba, + set_invalid, +) +from bec_widgets.widgets.containers.dock_area.profile_utils import is_profile_read_only +from bec_widgets.widgets.containers.dock_area.profile_ux.common import ( + NameCheck, + NameMode, + ProfileActions, + ProfileLibraryController, + absolute_time, + relative_time, +) + +TITLES = {"save": "Save profile", "rename": "Rename profile", "duplicate": "Duplicate profile"} + + +def tone_color(tokens: ThemeTokens, tone: str) -> QColor: + """Colour of a hint or banner tone.""" + return { + "error": tokens.danger, + "warning": tokens.warning, + "success": tokens.success, + "info": tokens.primary, + }.get(tone, tokens.fg_muted) + + +TONE_ICONS = { + "error": "error", + "warning": "warning", + "success": "check_circle", + "info": "info", + "neutral": "", +} + + +class PreviewFrame(QWidget): + """Rounded screenshot preview that keeps the aspect ratio, with a placeholder text.""" + + def __init__(self, parent: QWidget | None = None, placeholder: str = "No preview yet"): + super().__init__(parent) + self._pixmap: QPixmap | None = None + self._placeholder = placeholder + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.setMinimumSize(160, 100) + + def set_pixmap(self, pixmap: QPixmap | None, placeholder: str | None = None) -> None: + """Show *pixmap*, or the placeholder when it is None.""" + self._pixmap = pixmap if pixmap is not None and not pixmap.isNull() else None + if placeholder is not None: + self._placeholder = placeholder + self.update() + + def has_pixmap(self) -> bool: + """Whether a screenshot is shown.""" + return self._pixmap is not None + + def sizeHint(self) -> QSize: # pylint: disable=invalid-name + """Preferred preview size.""" + return QSize(320, 200) + + def paintEvent(self, _event): # pylint: disable=invalid-name + """Paint the screenshot or the placeholder.""" + tokens = ThemeTokens() + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + if self._pixmap is not None: + scaled = self._pixmap.size().scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio) + target = QRectF( + (self.width() - scaled.width()) / 2, + (self.height() - scaled.height()) / 2, + scaled.width(), + scaled.height(), + ) + path = QPainterPath() + path.addRoundedRect(target, 8, 8) + painter.setClipPath(path) + painter.drawPixmap(target.toRect(), self._pixmap) + painter.setClipping(False) + painter.setPen(tokens.border) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRoundedRect(target.adjusted(0.5, 0.5, -0.5, -0.5), 8, 8) + else: + painter.setPen(tokens.border) + painter.setBrush(tokens.soft(tokens.fg, 0.04)) + painter.drawRoundedRect(rect, 8, 8) + icon = material_icon("dashboard", size=(64, 64), color=tokens.fg_subtle) + icon_rect = QRectF(rect.center().x() - 16, rect.center().y() - 28, 32, 32) + painter.drawPixmap(icon_rect.toRect(), icon) + painter.setPen(tokens.fg_subtle) + font = self.font() + font.setPixelSize(12) + painter.setFont(font) + painter.drawText( + rect.adjusted(8, 40, -8, 0), + Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter, + self._placeholder, + ) + painter.end() + + +class HintLine(QWidget): + """Icon and text in the colour of a tone, used under fields and in banners.""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + self.icon = QLabel(self) + self.icon.setFixedSize(16, 16) + self.text = QLabel(self) + self.text.setWordWrap(True) + self.text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + layout.addWidget(self.icon, 0, Qt.AlignmentFlag.AlignTop) + layout.addWidget(self.text, 1) + self._tone = "neutral" + + def set_hint(self, tone: str, text: str) -> None: + """Show *text* in the colour of *tone*.""" + self._tone = tone + tokens = ThemeTokens() + color = tone_color(tokens, tone) + icon = TONE_ICONS.get(tone, "") + self.icon.setVisible(bool(icon)) + if icon: + self.icon.setPixmap(material_icon(icon, size=(32, 32), color=color).scaled(16, 16)) + text_color = tokens.fg_muted if tone == "neutral" else color + if tone == "warning" and not tokens.dark: + text_color = tokens.warning.darker(160) + self.text.setStyleSheet(f"color: {text_color.name()}; font-size: 12px;") + self.text.setText(text) + + +def _label(text: str, role: str = "body", parent: QWidget | None = None) -> QLabel: + tokens = ThemeTokens() + label = QLabel(text, parent) + styles = { + "title": f"color: {tokens.fg.name()}; font-size: 16px; font-weight: 600;", + "heading": f"color: {tokens.fg.name()}; font-size: 13px; font-weight: 600;", + "body": f"color: {tokens.fg.name()}; font-size: 13px;", + "muted": f"color: {tokens.fg_muted.name()}; font-size: 12px;", + "caption": ( + f"color: {tokens.fg_subtle.name()}; font-size: 11px; font-weight: 600;" + " letter-spacing: 0.5px;" + ), + } + label.setStyleSheet(styles[role]) + return label + + +################################################################################ +# Name dialog +################################################################################ + + +class ProfileNameDialog(QDialog): + """ + One dialog for saving, renaming and duplicating profiles. The name is checked while + typing; conflicts are explained under the field instead of in follow-up message boxes. + + Args: + actions(ProfileActions): Profile operations and checks. + mode(NameMode): ``save``, ``rename`` or ``duplicate``. + original(str): Profile being renamed or duplicated. + initial(str): Initial text of the name field. + preview(QPixmap | None): Screenshot shown next to the form. + parent(QWidget | None): Parent widget. + """ + + def __init__( + self, + actions: ProfileActions, + mode: NameMode, + original: str = "", + initial: str = "", + preview: QPixmap | None = None, + parent: QWidget | None = None, + notes: str = "", + pinned: bool = True, + ): + super().__init__(parent) + self.actions = actions + self.mode = mode + self.original = original + self._check: NameCheck | None = None + tokens = ThemeTokens() + self.setWindowTitle(TITLES[mode]) + self.setModal(True) + self.setObjectName("ProfileNameDialog") + self.setStyleSheet( + f"QDialog#ProfileNameDialog {{ background: {tokens.card.name()}; }}" + field_qss(tokens) + ) + + root = QVBoxLayout(self) + root.setContentsMargins(20, 18, 20, 16) + root.setSpacing(14) + heading = TITLES[mode] if mode == "save" else f"{TITLES[mode]} '{original}'" + root.addWidget(_label(heading, "title", self)) + + body = QHBoxLayout() + body.setSpacing(16) + self.preview = PreviewFrame(self, "Nothing to preview") + self.preview.setFixedSize(220, 140) + self.preview.set_pixmap(preview) + body.addWidget(self.preview, 0, Qt.AlignmentFlag.AlignTop) + + form = QVBoxLayout() + form.setSpacing(6) + form.addWidget(_label("Name", "heading", self)) + self.name_edit = QLineEdit(initial, self) + self.name_edit.setPlaceholderText("e.g. alignment, overview, tomo_scan") + self.name_edit.setMinimumWidth(300) + self.name_edit.selectAll() + form.addWidget(self.name_edit) + self.hint = HintLine(self) + form.addWidget(self.hint) + self.suggestion_button = TextButton("", "ghost", "auto_fix_high", self) + self.suggestion_button.setMinimumHeight(26) + self.suggestion_button.clicked.connect(self._use_suggestion) + form.addWidget(self.suggestion_button, 0, Qt.AlignmentFlag.AlignLeft) + + self.notes_edit = QLineEdit(notes, self) + self.pin_switch = ToggleSwitch(self) + self.pin_switch.setChecked(pinned) + if mode == "save": + form.addSpacing(6) + form.addWidget(_label("Description", "heading", self)) + self.notes_edit.setPlaceholderText("Optional, shown in the profile library") + form.addWidget(self.notes_edit) + form.addSpacing(6) + pin_row = QHBoxLayout() + pin_text = QVBoxLayout() + pin_text.setSpacing(0) + pin_text.addWidget(_label("Show in toolbar list", "heading", self)) + pin_text.addWidget(_label("Quick access from the profile drop-down.", "muted", self)) + pin_row.addLayout(pin_text, 1) + pin_row.addWidget(self.pin_switch) + form.addLayout(pin_row) + else: + self.notes_edit.hide() + self.pin_switch.hide() + form.addStretch(1) + body.addLayout(form, 1) + root.addLayout(body) + + buttons = QHBoxLayout() + buttons.addStretch(1) + self.cancel_button = TextButton("Cancel", "neutral", parent=self) + self.confirm_button = TextButton(TITLES[mode].split()[0], "primary", parent=self) + self.cancel_button.clicked.connect(self.reject) + self.confirm_button.clicked.connect(self._confirm) + self.confirm_button.setDefault(True) + buttons.addWidget(self.cancel_button) + buttons.addWidget(self.confirm_button) + root.addLayout(buttons) + + self.name_edit.textChanged.connect(self._validate) + self.name_edit.returnPressed.connect(self._confirm) + self._validate() + + @property + def check(self) -> NameCheck: + """The latest validation result.""" + return self._check + + def profile_name(self) -> str: + """The entered name.""" + return self.name_edit.text().strip() + + def notes(self) -> str: + """The entered description.""" + return self.notes_edit.text().strip() + + def pinned(self) -> bool: + """Whether the profile shows in the toolbar list.""" + return self.pin_switch.isChecked() + + def _validate(self, *_args) -> None: + check = self.actions.check(self.name_edit.text(), self.mode, self.original or None) + self._check = check + self.hint.set_hint(check.tone, check.message) + self.hint.setVisible(bool(check.message)) + set_invalid(self.name_edit, check.tone == "error") + self.suggestion_button.setVisible(bool(check.suggestion)) + if check.suggestion: + self.suggestion_button.setText(f"Use '{check.suggestion}'") + self.confirm_button.setText(check.action) + self.confirm_button.set_variant("danger" if check.replaces else "primary") + self.confirm_button.setEnabled(check.ok) + + def _use_suggestion(self) -> None: + if self._check and self._check.suggestion: + self.name_edit.setText(self._check.suggestion) + self.name_edit.setFocus() + + def _confirm(self) -> None: + if self._check is not None and self._check.ok: + self.accept() + + +################################################################################ +# Revert dialog +################################################################################ + + +class RevertProfileDialog(QDialog): + """ + Confirm reverting a profile to its saved layout, showing the current and the saved + layout side by side. Cancel is the default button. + + Args: + name(str): Profile name. + saved_at(str): ISO timestamp of the saved copy. + current(QPixmap | None): Screenshot of the current layout. + saved(QPixmap | None): Screenshot of the saved layout. + read_only(bool): Whether the profile is a read-only one (reverts to its original). + parent(QWidget | None): Parent widget. + """ + + def __init__( + self, + name: str, + saved_at: str, + current: QPixmap | None, + saved: QPixmap | None, + read_only: bool = False, + parent: QWidget | None = None, + ): + super().__init__(parent) + tokens = ThemeTokens() + self.setWindowTitle("Revert to saved layout") + self.setModal(True) + self.setObjectName("RevertProfileDialog") + self.setStyleSheet(f"QDialog#RevertProfileDialog {{ background: {tokens.card.name()}; }}") + root = QVBoxLayout(self) + root.setContentsMargins(20, 18, 20, 16) + root.setSpacing(12) + root.addWidget(_label(f"Revert '{name}' to its saved layout?", "title", self)) + when = relative_time(saved_at) + what = "its original layout" if read_only else "the layout saved" + text = f"Changes made since then are discarded. You go back to {what}" + text += f" {when}." if when and not read_only else "." + body = _label(text, "muted", self) + body.setWordWrap(True) + root.addWidget(body) + + row = QHBoxLayout() + row.setSpacing(12) + for caption, pixmap, sub in ( + ("NOW", current, "Current layout"), + ("AFTER REVERT", saved, absolute_time(saved_at) or "Saved layout"), + ): + column = QVBoxLayout() + column.setSpacing(6) + column.addWidget(_label(caption, "caption", self)) + frame = PreviewFrame(self, "No screenshot") + frame.setFixedSize(300, 190) + frame.set_pixmap(pixmap) + column.addWidget(frame) + column.addWidget(_label(sub, "muted", self)) + row.addLayout(column) + if caption == "NOW": + arrow = QLabel(self) + arrow.setPixmap( + material_icon("arrow_forward", size=(40, 40), color=tokens.fg_subtle).scaled( + 20, 20 + ) + ) + row.addWidget(arrow, 0, Qt.AlignmentFlag.AlignVCenter) + root.addLayout(row) + + buttons = QHBoxLayout() + buttons.addStretch(1) + self.cancel_button = TextButton("Keep current layout", "neutral", parent=self) + self.revert_button = TextButton("Revert", "danger", "history", self) + self.cancel_button.setDefault(True) + self.cancel_button.clicked.connect(self.reject) + self.revert_button.clicked.connect(self.accept) + buttons.addWidget(self.cancel_button) + buttons.addWidget(self.revert_button) + root.addLayout(buttons) + + +################################################################################ +# Library +################################################################################ + + +class _Row(QFrame): + """One profile row of the library list.""" + + pin_clicked = Signal(str) + restore_clicked = Signal(str) + + def __init__(self, row: dict, parent: QWidget | None = None): + super().__init__(parent) + tokens = ThemeTokens() + self.key = row["key"] + self.setObjectName("libRow") + self._selected = None + self.set_selected(row["selected"]) + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 6, 6, 6) + layout.setSpacing(8) + icon_name = {"trash": "delete", "bundled": "lock"}.get(row["kind"], "dashboard") + icon = QLabel(self) + icon_color = tokens.primary if row["isCurrent"] else tokens.fg_subtle + icon.setPixmap(material_icon(icon_name, size=(36, 36), color=icon_color).scaled(18, 18)) + icon.setStyleSheet("background: none; border: none;") + layout.addWidget(icon) + text = QVBoxLayout() + text.setSpacing(1) + name = QLabel(row["name"], self) + weight = 600 if row["isCurrent"] else 500 + fg = tokens.fg_muted if row["kind"] == "trash" else tokens.fg + name.setStyleSheet( + f"color: {fg.name()}; font-size: 13px; font-weight: {weight}; background: none;" + " border: none;" + ) + subtitle = QLabel(row["subtitle"], self) + subtitle.setStyleSheet( + f"color: {tokens.fg_subtle.name()}; font-size: 11px; background: none; border: none;" + ) + text.addWidget(name) + text.addWidget(subtitle) + layout.addLayout(text, 1) + if row["isCurrent"] or row["isOpen"]: + pill = StatusPill(self) + pill.set_status( + "This tab" if row["isCurrent"] else "Open", + tokens.primary if row["isCurrent"] else tokens.success, + ) + layout.addWidget(pill) + if row["kind"] == "trash": + restore = TextButton("Restore", "ghost", "restore_from_trash", self) + restore.setMinimumHeight(28) + restore.clicked.connect(lambda: self.restore_clicked.emit(row["token"])) + layout.addWidget(restore) + else: + pin = IconButton("star", "Show in toolbar list", self, size=16) + pin.setIcon( + material_icon( + "star", + size=(32, 32), + filled=row["pinned"], + color=tokens.warning if row["pinned"] else tokens.fg_subtle, + convert_to_pixmap=False, + ) + ) + pin.setToolTip( + "Shown in the toolbar list. Click to hide." + if row["pinned"] + else "Not in the toolbar list. Click to show." + ) + pin.clicked.connect(lambda: self.pin_clicked.emit(self.key)) + layout.addWidget(pin) + + def set_selected(self, selected: bool) -> None: + """Show the row as selected or not.""" + if selected == self._selected: + return + self._selected = selected + tokens = ThemeTokens() + bg = rgba(tokens.soft(tokens.primary, 0.14), 1.0) if selected else "transparent" + hover = bg if selected else tokens.hover.name() + border = tokens.primary.name() if selected else "transparent" + self.setStyleSheet( + f"QFrame#libRow {{ background: {bg}; border-radius: 8px; border: 1px solid {border}; }}" + f"QFrame#libRow:hover {{ background: {hover}; }}" + ) + + +class ProfileLibraryWidget(QWidget): + """ + The profile library: searchable list with sections on the left, the selected profile's + preview, description, facts and actions on the right. + + Args: + controller(ProfileLibraryController): State and actions. + parent(QWidget | None): Parent widget. + """ + + def __init__(self, controller: ProfileLibraryController, parent: QWidget | None = None): + super().__init__(parent) + self.controller = controller + self.setObjectName("ProfileLibrary") + tokens = ThemeTokens() + self.setStyleSheet( + f"QWidget#ProfileLibrary {{ background: {tokens.bg.name()}; }}" + field_qss(tokens) + ) + root = QVBoxLayout(self) + root.setContentsMargins(16, 14, 16, 14) + root.setSpacing(10) + + # header + header = QHBoxLayout() + header.setSpacing(10) + title_col = QVBoxLayout() + title_col.setSpacing(0) + self.title = _label("Profiles", "title", self) + self.count_label = _label("", "muted", self) + title_col.addWidget(self.title) + title_col.addWidget(self.count_label) + header.addLayout(title_col) + header.addStretch(1) + self.search = QLineEdit(self) + self.search.setPlaceholderText("Search profiles (Ctrl+F)") + self.search.setClearButtonEnabled(True) + self.search.setFixedWidth(260) + self.search.addAction( + material_icon("search", size=(32, 32), color=tokens.fg_subtle, convert_to_pixmap=False), + QLineEdit.ActionPosition.LeadingPosition, + ) + header.addWidget(self.search) + self.save_button = TextButton("Save current layout…", "primary", "save", self) + header.addWidget(self.save_button) + root.addLayout(header) + + # banner + self.banner = QFrame(self) + self.banner.setObjectName("libBanner") + banner_layout = QHBoxLayout(self.banner) + banner_layout.setContentsMargins(12, 6, 6, 6) + self.banner_hint = HintLine(self.banner) + banner_layout.addWidget(self.banner_hint, 1) + self.banner_action = TextButton("", "ghost", parent=self.banner) + self.banner_action.setMinimumHeight(28) + banner_layout.addWidget(self.banner_action) + self.banner_close = IconButton("close", "Dismiss", self.banner, size=16) + banner_layout.addWidget(self.banner_close) + root.addWidget(self.banner) + + # body + body = QHBoxLayout() + body.setSpacing(14) + self.list = QListWidget(self) + self.list.setFixedWidth(330) + self.list.setSpacing(1) + self.list.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel) + self.list.setStyleSheet( + f"QListWidget {{ background: {tokens.card.name()}; border: 1px solid" + f" {tokens.border.name()}; border-radius: 10px; padding: 6px; outline: none; }}" + "QListWidget::item, QListWidget::item:selected, QListWidget::item:hover" + " { background: transparent; border: none; }" + ) + body.addWidget(self.list) + + self.details = QFrame(self) + self.details.setObjectName("libDetails") + self.details.setStyleSheet( + f"QFrame#libDetails {{ background: {tokens.card.name()}; border: 1px solid" + f" {tokens.border.name()}; border-radius: 10px; }}" + ) + details = QVBoxLayout(self.details) + details.setContentsMargins(16, 16, 16, 14) + details.setSpacing(10) + self.preview = PreviewFrame(self.details) + self.preview.setMinimumHeight(220) + details.addWidget(self.preview, 1) + name_row = QHBoxLayout() + self.name_label = _label("", "title", self.details) + self.status_pill = StatusPill(self.details) + name_row.addWidget(self.name_label) + name_row.addWidget(self.status_pill) + name_row.addStretch(1) + self.delete_button = TextButton("Delete…", "ghost", "delete", self.details) + name_row.addWidget(self.delete_button) + details.addLayout(name_row) + self.notes_edit = QLineEdit(self.details) + self.notes_edit.setPlaceholderText("Add a description…") + details.addWidget(self.notes_edit) + self.read_only_hint = HintLine(self.details) + details.addWidget(self.read_only_hint) + self.facts = QGridLayout() + self.facts.setHorizontalSpacing(18) + self.facts.setVerticalSpacing(4) + details.addLayout(self.facts) + + actions = QHBoxLayout() + actions.setSpacing(8) + self.open_button = TextButton("Open in new tab", "primary", "tab", self.details) + self.open_here_button = TextButton("Open here", "neutral", "open_in_browser", self.details) + self.duplicate_button = TextButton("Duplicate…", "neutral", "content_copy", self.details) + self.rename_button = TextButton("Rename…", "neutral", "edit", self.details) + self.revert_button = TextButton("Revert…", "neutral", "history", self.details) + self.restore_button = TextButton("Restore", "primary", "restore_from_trash", self.details) + for button in ( + self.open_button, + self.open_here_button, + self.restore_button, + self.duplicate_button, + self.rename_button, + self.revert_button, + ): + actions.addWidget(button) + actions.addStretch(1) + details.addLayout(actions) + + self.confirm = QFrame(self.details) + self.confirm.setObjectName("libConfirm") + confirm_layout = QHBoxLayout(self.confirm) + confirm_layout.setContentsMargins(12, 8, 8, 8) + self.confirm_text = HintLine(self.confirm) + confirm_layout.addWidget(self.confirm_text, 1) + self.confirm_cancel = TextButton("Cancel", "neutral", parent=self.confirm) + self.confirm_delete = TextButton( + "Move to Recently deleted", "danger", "delete", self.confirm + ) + confirm_layout.addWidget(self.confirm_cancel) + confirm_layout.addWidget(self.confirm_delete) + details.addWidget(self.confirm) + body.addWidget(self.details, 1) + + self.empty = _label("", "muted", self) + self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + root.addLayout(body, 1) + + # wiring + self.search.textChanged.connect(controller.set_query) + self.search.installEventFilter(self) + self.list.currentItemChanged.connect(self._on_current_item) + self.list.itemDoubleClicked.connect(lambda *_: controller.open_selected(True)) + self.save_button.clicked.connect(lambda: controller.request_name("save")) + self.open_button.clicked.connect(lambda: controller.open_selected(True)) + self.open_here_button.clicked.connect(lambda: controller.open_selected(False)) + self.duplicate_button.clicked.connect(lambda: controller.request_name("duplicate")) + self.rename_button.clicked.connect(lambda: controller.request_name("rename")) + self.revert_button.clicked.connect(controller.request_revert) + self.restore_button.clicked.connect(lambda: controller.restore(None)) + self.delete_button.clicked.connect(controller.request_delete) + self.confirm_cancel.clicked.connect(controller.cancel_delete) + self.confirm_delete.clicked.connect(controller.confirm_delete) + self.banner_action.clicked.connect(controller.banner_action) + self.banner_close.clicked.connect(controller.dismiss_banner) + self.notes_edit.editingFinished.connect( + lambda: controller.save_notes(self.notes_edit.text()) + ) + controller.changed.connect(self.render) + controller.preview_changed.connect(self._render_preview) + for keys, slot in ( + ("Ctrl+F", self.search.setFocus), + ("F2", lambda: controller.request_name("rename")), + ("Ctrl+D", lambda: controller.request_name("duplicate")), + ("Ctrl+S", lambda: controller.request_name("save")), + ("Delete", controller.request_delete), + ): + shortcut = QShortcut(QKeySequence(keys), self) + shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + shortcut.activated.connect(slot) + self._rendering = False + self._list_signature = None + self.render() + self._render_preview() + + def eventFilter(self, obj, event): # pylint: disable=invalid-name + """Let Up/Down/Enter in the search field drive the list.""" + if obj is self.search and event.type() == event.Type.KeyPress: + if event.key() in (Qt.Key.Key_Down, Qt.Key.Key_Up): + self.controller.move_selection(1 if event.key() == Qt.Key.Key_Down else -1) + return True + if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + self.controller.open_selected(True) + return True + return super().eventFilter(obj, event) + + def _on_current_item(self, item: QListWidgetItem | None, _previous=None) -> None: + if self._rendering or item is None: + return + key = item.data(Qt.ItemDataRole.UserRole) + if key: + QTimer.singleShot(0, lambda: self.controller.select(key)) + + def _render_preview(self) -> None: + selected = self.controller.state.selected + if selected and selected["kind"] == "trash": + self.preview.set_pixmap(None, "Deleted profile. Restore it to open it.") + else: + self.preview.set_pixmap(self.controller.preview_for_selected(), "No preview yet") + + def render(self) -> None: + """Rebuild the list and the details from the controller state.""" + state = self.controller.state + tokens = ThemeTokens() + self._render_list(state) + self._render_header(state, tokens) + self._render_details(state, tokens) + + def _render_list(self, state) -> None: + signature = [tuple(v for k, v in row.items() if k != "selected") for row in state.rows] + signature.append(state.empty_text) + if signature == self._list_signature: + # only the selection moved: restyle the rows instead of rebuilding them + self._rendering = True + for index in range(self.list.count()): + item = self.list.item(index) + widget = self.list.itemWidget(item) + if isinstance(widget, _Row): + selected = widget.key == (state.selected or {}).get("key") + widget.set_selected(selected) + if selected: + self.list.setCurrentItem(item) + self.list.scrollToItem(item) + self._rendering = False + return + self._list_signature = signature + self._rendering = True + self.list.clear() + section = None + current_item = None + for row in state.rows: + if row["section"] != section: + section = row["section"] + header = QListWidgetItem(self.list) + header.setFlags(Qt.ItemFlag.NoItemFlags) + label = _label(section.upper(), "caption") + label.setContentsMargins(8, 10 if self.list.count() > 1 else 2, 0, 2) + header.setSizeHint(label.sizeHint()) + self.list.setItemWidget(header, label) + item = QListWidgetItem(self.list) + item.setData(Qt.ItemDataRole.UserRole, row["key"]) + widget = _Row(row) + widget.pin_clicked.connect(self.controller.toggle_pin) + widget.restore_clicked.connect(self.controller.restore) + item.setSizeHint(QSize(0, 48)) + self.list.setItemWidget(item, widget) + if row["selected"]: + current_item = item + if not state.rows: + item = QListWidgetItem(self.list) + item.setFlags(Qt.ItemFlag.NoItemFlags) + label = _label(state.empty_text, "muted") + label.setWordWrap(True) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + label.setContentsMargins(12, 30, 12, 30) + item.setSizeHint(QSize(0, 100)) + self.list.setItemWidget(item, label) + if current_item is not None: + self.list.setCurrentItem(current_item) + self.list.scrollToItem(current_item) + self._rendering = False + + def _render_header(self, state, tokens) -> None: + if self.search.text() != state.query and not self.search.hasFocus(): + blocked = self.search.blockSignals(True) + self.search.setText(state.query) + self.search.blockSignals(blocked) + count = state.total + self.count_label.setText(f"{count} profile" + ("" if count == 1 else "s")) + self.save_button.setVisible(True) + + banner = state.banner + self.banner.setVisible(banner is not None) + if banner is not None: + color = tone_color(tokens, banner.tone) + self.banner.setStyleSheet( + f"QFrame#libBanner {{ background: {tokens.soft(color, 0.14).name()};" + f" border: 1px solid {tokens.soft(color, 0.45).name()}; border-radius: 8px; }}" + ) + self.banner_hint.set_hint(banner.tone, banner.text) + self.banner_action.setVisible(bool(banner.action)) + self.banner_action.setText(banner.action) + + def _render_details(self, state, tokens) -> None: + selected = state.selected + self.details.setVisible(selected is not None) + if selected is None: + return + is_trash = selected["kind"] == "trash" + self.name_label.setText(selected["name"]) + status = selected["status"] + self.status_pill.setVisible(bool(status) and not is_trash) + if status: + self.status_pill.set_status( + status, tokens.primary if selected["isCurrent"] else tokens.success + ) + if not self.notes_edit.hasFocus(): + self.notes_edit.setText(selected["notes"] or "") + self.notes_edit.setVisible(not is_trash and not selected["readOnly"]) + if is_trash: + self.read_only_hint.set_hint("neutral", status) + elif selected["readOnly"]: + self.read_only_hint.set_hint( + "info", + f"Read-only profile from {selected['source']}. Your changes are kept as you" + " work; Revert brings back the original. Duplicate it to keep your own version.", + ) + self.read_only_hint.setVisible(is_trash or selected["readOnly"]) + + while self.facts.count(): + item = self.facts.takeAt(0) + if item.widget(): + item.widget().deleteLater() + for index, (key, value) in enumerate(selected["facts"]): + self.facts.addWidget(_label(key, "muted", self.details), index // 2, (index % 2) * 2) + self.facts.addWidget( + _label(value, "body", self.details), index // 2, (index % 2) * 2 + 1 + ) + self.facts.setColumnStretch(1, 1) + self.facts.setColumnStretch(3, 1) + + has_tabs = state.has_tabs + self.open_button.setVisible(not is_trash and not selected["isCurrent"]) + self.open_button.setText( + ("Go to tab" if selected["isOpen"] else "Open in new tab") if has_tabs else "Open" + ) + self.open_here_button.setVisible(not is_trash and has_tabs and not selected["isOpen"]) + self.restore_button.setVisible(is_trash) + self.duplicate_button.setVisible(not is_trash) + self.rename_button.setVisible(not is_trash and not selected["readOnly"]) + self.revert_button.setVisible(not is_trash) + self.revert_button.setEnabled(selected["canRevert"]) + self.revert_button.setToolTip( + "Go back to the saved layout" + if selected["canRevert"] + else "Nothing saved to go back to" + ) + self.delete_button.setVisible(not is_trash) + self.delete_button.setEnabled(selected["canDelete"]) + self.delete_button.setToolTip(selected["deleteReason"] or "Move to Recently deleted") + + pending = bool(state.pending_delete) + self.confirm.setVisible(pending) + if pending: + self.confirm.setStyleSheet( + f"QFrame#libConfirm {{ background: {tokens.soft(tokens.danger, 0.12).name()};" + f" border: 1px solid {tokens.soft(tokens.danger, 0.5).name()};" + " border-radius: 8px; }" + ) + self.confirm_text.set_hint( + "warning", + f"Move '{state.pending_delete}' to Recently deleted? You can restore it any time.", + ) + + +################################################################################ +# Windows and flows +################################################################################ + + +class ProfileLibraryDialog(QDialog): + """Non-modal window hosting :class:`ProfileLibraryWidget`.""" + + def __init__(self, actions: ProfileActions, parent: QWidget | None = None): + super().__init__(parent) + self.setWindowTitle("Profile library") + self.setModal(False) + self.resize(1040, 640) + self.setMinimumSize(820, 520) + self.controller = ProfileLibraryController(actions, self) + self.library = ProfileLibraryWidget(self.controller, self) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.library) + self.controller.name_requested.connect(self._ask_name) + self.controller.revert_requested.connect(self._ask_revert) + + def _ask_name(self, mode: str, original: str) -> None: + actions = self.controller.actions + form = actions.name_form(mode, original) + preview = actions.live_preview() if mode == "save" else actions.preview(original) + dialog = ProfileNameDialog( + actions, mode, original, form["initial"], preview, self, form["notes"], form["pinned"] + ) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + self.controller.apply_name( + mode, original, dialog.profile_name(), notes=dialog.notes(), pinned=dialog.pinned() + ) + + def _ask_revert(self, name: str) -> None: + if run_revert_flow(self.controller.actions, name, self): + self.controller.apply_revert(name) + + +def run_name_flow( + actions: ProfileActions, mode: NameMode, original: str = "", parent: QWidget | None = None +) -> str | None: + """ + Show the name dialog and run the save, rename or duplicate. + + Returns: + str | None: The new name, or None when cancelled. + """ + form = actions.name_form(mode, original) + preview = actions.preview(original) if mode == "duplicate" else actions.live_preview() + dialog = ProfileNameDialog( + actions, mode, original, form["initial"], preview, parent, form["notes"], form["pinned"] + ) + if dialog.exec() != QDialog.DialogCode.Accepted: + return None + actions.apply_name(mode, original, dialog.profile_name(), dialog.notes(), dialog.pinned()) + return dialog.profile_name() + + +def run_revert_flow(actions: ProfileActions, name: str, parent: QWidget | None = None) -> bool: + """Ask whether to revert *name*; returns True when confirmed (the caller reverts).""" + current = actions.live_preview(name) or actions.preview(name) + dialog = RevertProfileDialog( + name, + actions.saved_at(name), + current, + actions.saved_preview(name), + is_profile_read_only(name, actions.namespace), + parent, + ) + return dialog.exec() == QDialog.DialogCode.Accepted diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/qml/HintLine.qml b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/HintLine.qml new file mode 100644 index 00000000..3080b3d9 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/HintLine.qml @@ -0,0 +1,35 @@ +import QtQuick +import QtQuick.Layouts +import BecUi + +// Icon and text in the colour of a tone: neutral, info, success, warning or error. +RowLayout { + id: root + property string tone: "neutral" + property string text: "" + readonly property color toneColor: tone === "error" ? theme.danger + : tone === "warning" ? theme.warning + : tone === "success" ? theme.success + : tone === "info" ? theme.primary + : theme.fgMuted + readonly property color textColor: tone === "neutral" ? theme.fgMuted + : tone === "warning" && !theme.dark ? Qt.darker(theme.warning, 1.6) + : toneColor + spacing: 6 + Icon { + Layout.alignment: Qt.AlignTop + Layout.topMargin: 1 + visible: root.tone !== "neutral" + size: 16 + color: root.toneColor + name: root.tone === "error" ? "error" : root.tone === "warning" ? "warning" + : root.tone === "success" ? "check_circle" : "info" + } + Text { + Layout.fillWidth: true + text: root.text + color: root.textColor + font.pixelSize: 12 + wrapMode: Text.WordWrap + } +} diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/qml/PreviewFrame.qml b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/PreviewFrame.qml new file mode 100644 index 00000000..6994b66e --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/PreviewFrame.qml @@ -0,0 +1,50 @@ +import QtQuick +import BecUi + +// Screenshot preview that keeps the aspect ratio, with a placeholder text. +Item { + id: root + property string source: "" + property string placeholder: "No preview yet" + readonly property bool hasImage: source !== "" && image.status === Image.Ready + + Rectangle { + anchors.fill: parent + visible: !root.hasImage + radius: 8 + color: Qt.rgba(theme.fg.r, theme.fg.g, theme.fg.b, 0.04) + border.color: theme.border + Column { + anchors.centerIn: parent + spacing: 6 + Icon { anchors.horizontalCenter: parent.horizontalCenter; name: "dashboard"; size: 32; color: theme.fgSubtle } + Text { + anchors.horizontalCenter: parent.horizontalCenter + width: root.width - 16 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + text: root.placeholder + color: theme.fgSubtle + font.pixelSize: 12 + } + } + } + Image { + id: image + anchors.fill: parent + source: root.source + cache: false + smooth: true + mipmap: true + fillMode: Image.PreserveAspectFit + } + Rectangle { + visible: root.hasImage + anchors.centerIn: parent + width: image.paintedWidth + 1 + height: image.paintedHeight + 1 + radius: 4 + color: "transparent" + border.color: theme.border + } +} diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/qml/ProfileLibrary.qml b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/ProfileLibrary.qml new file mode 100644 index 00000000..4acee492 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/ProfileLibrary.qml @@ -0,0 +1,400 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import BecUi + +// Profile library: searchable list with sections on the left, the selected profile's +// preview, description, facts and actions on the right. +Rectangle { + id: root + required property var backend + readonly property var st: backend.state + readonly property var sel: st.selected || ({}) + readonly property bool hasSelection: sel.key !== undefined + readonly property bool isTrash: sel.kind === "trash" + readonly property var banner: st.banner || ({}) + + color: theme.bg + + function toneColor(tone) { + return tone === "error" ? theme.danger : tone === "warning" ? theme.warning + : tone === "success" ? theme.success : tone === "info" ? theme.primary : theme.fgMuted + } + function soft(c, a) { return Qt.rgba(c.r, c.g, c.b, a) } + + Shortcut { sequence: "Ctrl+F"; onActivated: search.forceActiveFocus() } + Shortcut { sequence: "F2"; onActivated: root.backend.requestName("rename") } + Shortcut { sequence: "Ctrl+D"; onActivated: root.backend.requestName("duplicate") } + Shortcut { sequence: "Ctrl+S"; onActivated: root.backend.requestName("save") } + Shortcut { sequence: "Delete"; enabled: !notes.activeFocus && !search.activeFocus; onActivated: root.backend.requestDelete() } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.topMargin: 14 + anchors.bottomMargin: 14 + spacing: 10 + + // ---------------------------------------------------------------- header + RowLayout { + Layout.fillWidth: true + spacing: 10 + ColumnLayout { + spacing: 0 + Text { text: "Profiles"; color: theme.fg; font.pixelSize: 16; font.weight: Font.DemiBold } + Text { + text: root.st.total + (root.st.total === 1 ? " profile" : " profiles") + color: theme.fgMuted + font.pixelSize: 12 + } + } + Item { Layout.fillWidth: true } + InputField { + id: search + Layout.preferredWidth: 260 + placeholderText: "Search profiles (Ctrl+F)" + leftPadding: 30 + text: root.st.query + onTextEdited: root.backend.setQuery(text) + Keys.onDownPressed: root.backend.moveSelection(1) + Keys.onUpPressed: root.backend.moveSelection(-1) + Keys.onReturnPressed: root.backend.openSelected(true) + Icon { + anchors.left: parent.left + anchors.leftMargin: 9 + anchors.verticalCenter: parent.verticalCenter + name: "search" + size: 16 + color: theme.fgSubtle + } + } + TextButton { + text: "Save current layout…" + variant: "primary" + iconName: "save" + onClicked: root.backend.requestName("save") + } + } + + // ---------------------------------------------------------------- banner + Rectangle { + Layout.fillWidth: true + visible: root.banner.text !== undefined + implicitHeight: bannerRow.implicitHeight + 12 + radius: 8 + color: root.soft(root.toneColor(root.banner.tone), 0.14) + border.color: root.soft(root.toneColor(root.banner.tone), 0.45) + RowLayout { + id: bannerRow + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 6 + spacing: 6 + HintLine { + Layout.fillWidth: true + tone: root.banner.tone || "neutral" + text: root.banner.text || "" + } + TextButton { + visible: (root.banner.action || "") !== "" + text: root.banner.action || "" + variant: "ghost" + implicitHeight: 28 + onClicked: root.backend.bannerAction() + } + IconButton { iconName: "close"; tip: "Dismiss"; iconSize: 16; onClicked: root.backend.dismissBanner() } + } + } + + // ---------------------------------------------------------------- body + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 14 + + Rectangle { + Layout.preferredWidth: 330 + Layout.fillHeight: true + radius: 10 + color: theme.card + border.color: theme.border + + ListView { + id: list + anchors.fill: parent + anchors.margins: 6 + clip: true + model: root.backend.rows + spacing: 1 + boundsBehavior: Flickable.StopAtBounds + currentIndex: -1 + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + section.property: "section" + section.delegate: Text { + required property string section + width: ListView.view.width + topPadding: 10 + bottomPadding: 4 + leftPadding: 8 + text: section.toUpperCase() + color: theme.fgSubtle + font.pixelSize: 11 + font.weight: Font.DemiBold + font.letterSpacing: 0.5 + } + delegate: Rectangle { + id: row + required property var model + width: ListView.view.width + height: 48 + radius: 8 + color: model.selected ? root.soft(theme.primary, 0.14) + : rowMouse.containsMouse ? theme.hover : "transparent" + border.color: model.selected ? theme.primary : "transparent" + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.backend.select(row.model.key) + onDoubleClicked: if (row.model.kind !== "trash") root.backend.openSelected(true) + } + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 6 + spacing: 8 + Icon { + name: row.model.kind === "trash" ? "delete" : row.model.kind === "bundled" ? "lock" : "dashboard" + size: 18 + color: row.model.isCurrent ? theme.primary : theme.fgSubtle + } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { + Layout.fillWidth: true + text: row.model.name + color: row.model.kind === "trash" ? theme.fgMuted : theme.fg + font.pixelSize: 13 + font.weight: row.model.isCurrent ? Font.DemiBold : Font.Medium + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: row.model.subtitle + color: theme.fgSubtle + font.pixelSize: 11 + elide: Text.ElideRight + } + } + StatusPill { + visible: row.model.isCurrent || row.model.isOpen + text: row.model.isCurrent ? "This tab" : "Open" + tone: row.model.isCurrent ? theme.primary : theme.success + } + TextButton { + visible: row.model.kind === "trash" + text: "Restore" + variant: "ghost" + iconName: "restore_from_trash" + implicitHeight: 28 + onClicked: root.backend.restore(row.model.token) + } + AbstractButton { + id: pin + visible: row.model.kind !== "trash" + implicitWidth: 30 + implicitHeight: 30 + hoverEnabled: true + onClicked: root.backend.togglePin(row.model.key) + background: Rectangle { radius: 6; color: pin.hovered ? theme.hover : "transparent" } + contentItem: Item { + Icon { + anchors.centerIn: parent + name: "star" + size: 16 + filled: row.model.pinned + color: row.model.pinned ? theme.warning : theme.fgSubtle + } + } + ToolTip.visible: hovered + ToolTip.delay: 500 + ToolTip.text: row.model.pinned ? "Shown in the toolbar list. Click to hide." + : "Not in the toolbar list. Click to show." + } + } + } + } + Text { + anchors.centerIn: parent + width: parent.width - 32 + visible: list.count === 0 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + text: root.st.emptyText + color: theme.fgMuted + font.pixelSize: 12 + } + } + + // ------------------------------------------------------------ details + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + visible: root.hasSelection + clip: true + radius: 10 + color: theme.card + border.color: theme.border + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.topMargin: 16 + anchors.bottomMargin: 14 + spacing: 10 + + PreviewFrame { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 220 + source: root.isTrash ? "" : root.backend.previewUrl + placeholder: root.isTrash ? "Deleted profile. Restore it to open it." : "No preview yet" + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Text { + text: root.sel.name || "" + color: theme.fg + font.pixelSize: 16 + font.weight: Font.DemiBold + } + StatusPill { + visible: !root.isTrash && (root.sel.status || "") !== "" + text: root.sel.status || "" + tone: root.sel.isCurrent ? theme.primary : theme.success + } + Item { Layout.fillWidth: true } + TextButton { + visible: !root.isTrash + enabled: root.sel.canDelete === true + variant: "ghost" + iconName: "delete" + text: "Delete…" + tip: root.sel.deleteReason || "Move to Recently deleted" + onClicked: root.backend.requestDelete() + } + } + InputField { + id: notes + Layout.fillWidth: true + visible: !root.isTrash && !root.sel.readOnly + placeholderText: "Add a description…" + text: root.sel.notes || "" + onEditingFinished: root.backend.saveNotes(text) + } + HintLine { + Layout.fillWidth: true + visible: root.isTrash || root.sel.readOnly === true + tone: root.isTrash ? "neutral" : "info" + text: root.isTrash ? (root.sel.status || "") + : "Read-only profile from " + (root.sel.source || "BEC") + ". Your changes are kept as you work; " + + "Revert brings back the original. Duplicate it to keep your own version." + } + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 18 + rowSpacing: 4 + Repeater { + model: root.sel.facts || [] + delegate: RowLayout { + required property var modelData + Layout.fillWidth: true + Layout.preferredWidth: 1 + spacing: 12 + Text { text: modelData.label; color: theme.fgMuted; font.pixelSize: 12; Layout.preferredWidth: 56 } + Text { text: modelData.value; color: theme.fg; font.pixelSize: 13; Layout.fillWidth: true; elide: Text.ElideRight } + } + } + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + TextButton { + visible: !root.isTrash && !root.sel.isCurrent + variant: "primary" + iconName: "tab" + text: root.st.hasTabs ? (root.sel.isOpen ? "Go to tab" : "Open in new tab") : "Open" + onClicked: root.backend.openSelected(true) + } + TextButton { + visible: !root.isTrash && root.st.hasTabs && !root.sel.isOpen + iconName: "open_in_browser" + text: "Open here" + onClicked: root.backend.openSelected(false) + } + TextButton { + visible: root.isTrash + variant: "primary" + iconName: "restore_from_trash" + text: "Restore" + onClicked: root.backend.restore("") + } + TextButton { + visible: !root.isTrash + iconName: "content_copy" + text: "Duplicate…" + onClicked: root.backend.requestName("duplicate") + } + TextButton { + visible: !root.isTrash && !root.sel.readOnly + iconName: "edit" + text: "Rename…" + onClicked: root.backend.requestName("rename") + } + TextButton { + visible: !root.isTrash + enabled: root.sel.canRevert === true + iconName: "history" + text: "Revert…" + tip: enabled ? "Go back to the saved layout" : "Nothing saved to go back to" + onClicked: root.backend.requestRevert() + } + Item { Layout.fillWidth: true } + } + Rectangle { + Layout.fillWidth: true + visible: root.st.pendingDelete !== "" + implicitHeight: confirmRow.implicitHeight + 16 + radius: 8 + color: root.soft(theme.danger, 0.12) + border.color: root.soft(theme.danger, 0.5) + RowLayout { + id: confirmRow + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 8 + spacing: 8 + HintLine { + Layout.fillWidth: true + tone: "warning" + text: "Move '" + root.st.pendingDelete + "' to Recently deleted? You can restore it any time." + } + TextButton { text: "Cancel"; onClicked: root.backend.cancelDelete() } + TextButton { + text: "Delete" + variant: "danger" + iconName: "delete" + onClicked: root.backend.confirmDelete() + } + } + } + } + } + } + } +} diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/qml/ProfileNameDialog.qml b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/ProfileNameDialog.qml new file mode 100644 index 00000000..57ac04d1 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/ProfileNameDialog.qml @@ -0,0 +1,124 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import BecUi + +// Save, rename and duplicate a profile. The name is checked while typing; conflicts are +// explained under the field instead of in follow-up message boxes. +Rectangle { + id: root + required property var backend + readonly property var check: backend.check + color: theme.card + + Keys.onEscapePressed: backend.cancel() + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.topMargin: 18 + anchors.bottomMargin: 16 + spacing: 14 + + Text { + text: root.backend.heading + color: theme.fg + font.pixelSize: 16 + font.weight: Font.DemiBold + Layout.fillWidth: true + elide: Text.ElideRight + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 16 + + PreviewFrame { + Layout.alignment: Qt.AlignTop + Layout.preferredWidth: 220 + Layout.preferredHeight: 140 + source: root.backend.previewUrl + placeholder: "Nothing to preview" + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: 6 + + Text { text: "Name"; color: theme.fg; font.pixelSize: 13; font.weight: Font.DemiBold } + InputField { + id: nameField + Layout.fillWidth: true + text: root.backend.initialName + placeholderText: "e.g. alignment, overview, tomo_scan" + invalid: root.check.tone === "error" + focus: true + onTextChanged: root.backend.setName(text) + onAccepted: root.backend.confirm() + Component.onCompleted: { selectAll(); forceActiveFocus() } + } + HintLine { + Layout.fillWidth: true + visible: root.check.message !== "" + tone: root.check.tone + text: root.check.message + } + TextButton { + visible: root.check.suggestion !== "" + variant: "ghost" + iconName: "auto_fix_high" + implicitHeight: 26 + text: "Use '" + root.check.suggestion + "'" + onClicked: { nameField.text = root.check.suggestion; nameField.forceActiveFocus() } + } + + ColumnLayout { + visible: root.backend.mode === "save" + Layout.fillWidth: true + Layout.topMargin: 6 + spacing: 6 + Text { text: "Description"; color: theme.fg; font.pixelSize: 13; font.weight: Font.DemiBold } + InputField { + Layout.fillWidth: true + text: root.backend.initialNotes + placeholderText: "Optional, shown in the profile library" + onTextChanged: root.backend.setNotes(text) + onAccepted: root.backend.confirm() + } + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 6 + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { text: "Show in toolbar list"; color: theme.fg; font.pixelSize: 13; font.weight: Font.DemiBold } + Text { text: "Quick access from the profile drop-down."; color: theme.fgMuted; font.pixelSize: 12 } + } + SwitchField { + checked: root.backend.initialPinned + text: checked ? "On" : "Off" + onToggled: root.backend.setPinned(checked) + } + } + } + Item { Layout.fillHeight: true } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Item { Layout.fillWidth: true } + TextButton { text: "Cancel"; variant: "neutral"; onClicked: root.backend.cancel() } + TextButton { + text: root.check.action + variant: root.check.replaces ? "danger" : "primary" + enabled: root.check.ok + onClicked: root.backend.confirm() + } + } + } +} diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/qml/RevertProfileDialog.qml b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/RevertProfileDialog.qml new file mode 100644 index 00000000..0f8bfb76 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/qml/RevertProfileDialog.qml @@ -0,0 +1,101 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import BecUi + +// Confirm reverting a profile to its saved layout; current and saved layout side by side. +Rectangle { + id: root + required property var backend + color: theme.card + Keys.onEscapePressed: backend.cancel() + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.topMargin: 18 + anchors.bottomMargin: 16 + spacing: 12 + + Text { + Layout.fillWidth: true + text: "Revert '" + root.backend.name + "' to its saved layout?" + color: theme.fg + font.pixelSize: 16 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: root.backend.text + color: theme.fgMuted + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 12 + Repeater { + model: [ + { caption: "NOW", url: root.backend.nowUrl, sub: "Current layout" }, + { caption: "AFTER REVERT", url: root.backend.savedUrl, sub: root.backend.savedCaption } + ] + delegate: RowLayout { + required property var modelData + required property int index + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.fillHeight: true + spacing: 12 + Icon { + visible: index === 1 + name: "arrow_forward" + size: 20 + color: theme.fgSubtle + } + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.fillHeight: true + spacing: 6 + Text { + text: modelData.caption + color: theme.fgSubtle + font.pixelSize: 11 + font.weight: Font.DemiBold + font.letterSpacing: 0.5 + } + PreviewFrame { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 150 + source: modelData.url + placeholder: "No screenshot" + } + Text { text: modelData.sub; color: theme.fgMuted; font.pixelSize: 12 } + } + } + } + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Item { Layout.fillWidth: true } + TextButton { + text: "Keep current layout" + variant: "neutral" + focus: true + onClicked: root.backend.cancel() + Keys.onReturnPressed: root.backend.cancel() + } + TextButton { + text: "Revert" + variant: "danger" + iconName: "history" + onClicked: root.backend.confirm() + } + } + } +} diff --git a/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py index b3d46df9..b233c035 100644 --- a/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py @@ -123,27 +123,43 @@ class ProfileComboBox(QComboBox): self.blockSignals(was_blocked) -def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle: +def workspace_bundle( + components: ToolbarComponents, enable_tools: bool = True, reworked: bool = False +) -> ToolbarBundle: """ Creates a workspace toolbar bundle for BECDockArea. Args: components (ToolbarComponents): The components to be added to the bundle. enable_tools(bool): If True, show the workspace management tools; otherwise, only show the profile combo. + reworked(bool): Use the icons and labels of the reworked profile windows. Returns: ToolbarBundle: The workspace toolbar bundle. """ # Workspace combo + labels = { + "save": ("save", "Save Current Workspace"), + "restore": ("undo", "Restore Baseline Profile"), + "manage": ("manage_accounts", "Manage"), + } + if reworked: + labels = { + "save": ("save", "Save profile…"), + "restore": ("history", "Revert to saved layout…"), + "manage": ("folder_open", "Profile library"), + } combo = ProfileComboBox(parent=components.toolbar) combo.setVisible(enable_tools) + if reworked: + combo.setToolTip("Profiles shown in the toolbar list. Open the library for all profiles.") components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False)) components.add_safe( "save_workspace", MaterialIconAction( - icon_name="save", - tooltip="Save Current Workspace", + icon_name=labels["save"][0], + tooltip=labels["save"][1], checkable=False, parent=components.toolbar, ), @@ -153,8 +169,8 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) - components.add_safe( "reset_baseline_workspace", MaterialIconAction( - icon_name="undo", - tooltip="Restore Baseline Profile", + icon_name=labels["restore"][0], + tooltip=labels["restore"][1], checkable=False, parent=components.toolbar, ), @@ -164,7 +180,10 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) - components.add_safe( "manage_workspaces", MaterialIconAction( - icon_name="manage_accounts", tooltip="Manage", checkable=True, parent=components.toolbar + icon_name=labels["manage"][0], + tooltip=labels["manage"][1], + checkable=True, + parent=components.toolbar, ), ) components.get_action("manage_workspaces").action.setVisible(enable_tools) diff --git a/bec_widgets/widgets/containers/dock_area/workspace_tabs.py b/bec_widgets/widgets/containers/dock_area/workspace_tabs.py index df4f216d..918b97fb 100644 --- a/bec_widgets/widgets/containers/dock_area/workspace_tabs.py +++ b/bec_widgets/widgets/containers/dock_area/workspace_tabs.py @@ -21,11 +21,17 @@ 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 @@ -278,7 +284,9 @@ class WorkspaceTabs(QTabWidget): @SafeSlot() @SafeSlot(int) - def rename_workspace(self, index: int | None = None, name: str | None = None) -> bool: + 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). @@ -288,6 +296,8 @@ class WorkspaceTabs(QTabWidget): 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. @@ -299,6 +309,13 @@ class WorkspaceTabs(QTabWidget): 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 "" @@ -309,7 +326,9 @@ class WorkspaceTabs(QTabWidget): if not name or name == current: return False - if not self._may_rename_to(name): + 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) @@ -359,6 +378,7 @@ class WorkspaceTabs(QTabWidget): 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 @@ -389,6 +409,8 @@ class WorkspaceTabs(QTabWidget): 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: @@ -455,14 +477,63 @@ class WorkspaceTabs(QTabWidget): 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) - menu.addAction("Rename…", lambda: self.rename_workspace(index)) - menu.addAction("Close", lambda: self.close_workspace(index)) + 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("New Workspace", self.new_workspace) + menu.addAction(material_icon("folder_open"), "Profile library…", self.show_library) + menu.addAction(material_icon("add"), "New Workspace", self.new_workspace) + menu.addSeparator() + menu.addAction(material_icon("close"), "Close tab", lambda: self.close_workspace(index)) menu.exec(self.tabBar().mapToGlobal(pos)) + + def _tab_flow(self, index: int, mode: str, original: str | None = None) -> None: + if index != self.currentIndex(): + self.setCurrentIndex(index) + dock_area = self._materialize(self.widget(index)) + ask_profile_name(dock_area, mode, original or "", parent=self) + + def _tab_revert(self, index: int) -> None: + if index != self.currentIndex(): + self.setCurrentIndex(index) + dock_area = self._materialize(self.widget(index)) + dock_area.restore_baseline_profile(show_dialog=True) + + @SafeSlot() + def show_library(self) -> None: + """Open the profile library of the current workspace.""" + dock_area = self.current_dock_area() + if dock_area is not None: + dock_area.show_workspace_manager() diff --git a/tests/unit_tests/test_profile_ux.py b/tests/unit_tests/test_profile_ux.py new file mode 100644 index 00000000..af9d6146 --- /dev/null +++ b/tests/unit_tests/test_profile_ux.py @@ -0,0 +1,460 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, redefined-outer-name +# pylint: disable=protected-access +import shutil + +import pytest +from qtpy.QtCore import QDateTime, Qt +from qtpy.QtQuickWidgets import QQuickWidget +from qtpy.QtWidgets import QDialog + +from bec_widgets.tests.client_mocks import mocked_client +from bec_widgets.widgets.containers.dock_area import profile_utils +from bec_widgets.widgets.containers.dock_area.profile_utils import ( + copy_profile, + get_profile_info, + is_quick_select, + list_profiles, + list_trashed_profiles, + profile_origin, + restore_trashed_profile, + set_profile_notes, + trash_profile, +) +from bec_widgets.widgets.containers.dock_area.profile_ux import ( + POLICY, + ask_profile_name, + create_profile_library, + profile_qml, + profile_qwidget, + profile_ui_mode, +) +from bec_widgets.widgets.containers.dock_area.profile_ux.common import ( + SECTION_BUNDLED, + SECTION_MINE, + SECTION_TRASH, + ProfileActions, + ProfileLibraryController, + check_profile_name, + relative_time, + unique_name, +) +from bec_widgets.widgets.containers.dock_area.workspace_tabs import WorkspaceTabs + +NAMESPACE = "ux_test" +WIDGET = "Waveform" + + +@pytest.fixture(autouse=True) +def isolate_profile_storage(tmp_path, monkeypatch): + monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(tmp_path / "profiles_root")) + bundled = tmp_path / "bundled" + bundled.mkdir() + monkeypatch.setattr(profile_utils, "module_profiles_dir", lambda: str(bundled)) + yield bundled + + +@pytest.fixture(autouse=True) +def default_policy(): + keeps, one_tab = POLICY.rename_keeps_old_profile, POLICY.one_tab_per_profile + yield + POLICY.rename_keeps_old_profile, POLICY.one_tab_per_profile = keeps, one_tab + + +@pytest.fixture +def make_tabs(qtbot, mocked_client, monkeypatch): + created = [] + + def _make(ui="qwidget"): + monkeypatch.setenv("BEC_PROFILE_UI", ui) + tabs = WorkspaceTabs(profile_namespace=NAMESPACE, client=mocked_client) + qtbot.addWidget(tabs) + tabs.show() + qtbot.waitExposed(tabs) + created.append(tabs) + return tabs + + yield _make + for tabs in created: + tabs.cleanup() + + +def _save(tabs, name, **kwargs): + tabs.current_dock_area().save_profile(name, show_dialog=False, **kwargs) + + +def _bundle(tabs, bundled, name): + """Turn the current layout into a read-only bundled profile called *name*.""" + _save(tabs, "_src") + (bundled / f"{name}.ini").write_bytes( + open(profile_utils.runtime_profile_path("_src", NAMESPACE), "rb").read() + ) + tabs.current_dock_area()._current_profile_name = None + trash_profile("_src", NAMESPACE) + shutil.rmtree(profile_utils._trash_root(NAMESPACE)) + list_profiles(NAMESPACE) + + +def _accept_with(monkeypatch, cls, name, extra=None): + """Make *cls*.exec accept with *name* typed into the field.""" + + def fake_exec(dialog): + if hasattr(dialog, "name_edit"): + dialog.name_edit.setText(name) + else: + dialog.backend.setName(name) + if extra: + extra(dialog) + return QDialog.DialogCode.Accepted if dialog_ok(dialog) else QDialog.DialogCode.Rejected + + def dialog_ok(dialog): + return dialog.check.ok if hasattr(dialog, "name_edit") else dialog.backend.check["ok"] + + monkeypatch.setattr(cls, "exec", fake_exec) + + +################################################################################ +# Rules +################################################################################ + + +def test_ui_mode_defaults_to_legacy(monkeypatch): + monkeypatch.delenv("BEC_PROFILE_UI", raising=False) + assert profile_ui_mode() == "legacy" + monkeypatch.setenv("BEC_PROFILE_UI", "QML") + assert profile_ui_mode() == "qml" + monkeypatch.setenv("BEC_PROFILE_UI", "nonsense") + assert profile_ui_mode() == "legacy" + + +def test_relative_time(): + now = QDateTime.fromString("2026-10-09T12:00:00Z", Qt.DateFormat.ISODate) + assert relative_time("2026-10-09T11:59:30Z", now) == "just now" + assert relative_time("2026-10-09T11:15:00Z", now) == "45 min ago" + assert relative_time("2026-10-09T09:00:00Z", now) == "3 h ago" + assert relative_time("2026-10-08T09:00:00Z", now) == "yesterday" + assert relative_time("2026-10-04T12:00:00Z", now) == "5 days ago" + assert relative_time("garbage", now) == "" + + +def test_unique_name(): + taken = {"a_copy", "a_copy_2"} + assert unique_name("a", taken.__contains__) == "a_copy_3" + assert unique_name("b", taken.__contains__, "custom") == "b_custom" + + +def test_name_check_basic_rules(make_tabs): + tabs = make_tabs() + _save(tabs, "alignment") + check = lambda text, mode="save", **kw: check_profile_name( # noqa: E731 + text, mode, namespace=NAMESPACE, **kw + ) + assert not check(" ").ok + bad = check("a/b") + assert not bad.ok and bad.tone == "error" and "/" in bad.message + assert not check(".hidden").ok + assert not check("x" * 65).ok + assert not check("alignment", "rename", original="alignment").ok + update = check("alignment", current="alignment") + assert update.ok and update.action == "Save" and not update.replaces + replace = check("alignment", current="other") + assert replace.ok and replace.replaces and replace.action == "Replace" + fresh = check("brand_new") + assert fresh.ok and fresh.tone == "neutral" + + +def test_name_check_read_only_suggests_free_name(make_tabs, isolate_profile_storage): + tabs = make_tabs() + _bundle(tabs, isolate_profile_storage, "overview") + result = check_profile_name("overview", "save", namespace=NAMESPACE) + assert not result.ok + assert result.suggestion == "overview_custom" + + +def test_name_check_open_elsewhere_depends_on_policy(make_tabs): + tabs = make_tabs() + _save(tabs, "first") + tabs.new_workspace() + actions = ProfileActions(tabs.current_dock_area()) + blocked = actions.check("first", "save") + assert not blocked.ok and "another tab" in blocked.message + POLICY.one_tab_per_profile = False + assert actions.check("first", "save").ok + + +def test_rename_hint_follows_policy(make_tabs): + tabs = make_tabs() + _save(tabs, "old") + keep = check_profile_name("new", "rename", namespace=NAMESPACE, original="old") + assert "stays in the library" in keep.message + POLICY.rename_keeps_old_profile = False + move = check_profile_name("new", "rename", namespace=NAMESPACE, original="old") + assert "Recently deleted" in move.message + + +################################################################################ +# Storage helpers +################################################################################ + + +def test_trash_and_restore_round_trip(make_tabs): + tabs = make_tabs() + _save(tabs, "keep_me") + tabs.current_dock_area()._current_profile_name = None + entry = trash_profile("keep_me", NAMESPACE) + assert entry is not None + assert "keep_me" not in list_profiles(NAMESPACE) + assert [t.name for t in list_trashed_profiles(NAMESPACE)] == ["keep_me"] + assert restore_trashed_profile(entry.token, NAMESPACE) == "keep_me" + assert "keep_me" in list_profiles(NAMESPACE) + assert list_trashed_profiles(NAMESPACE) == [] + + +def test_restore_refuses_when_name_is_taken_again(make_tabs): + tabs = make_tabs() + _save(tabs, "dup") + entry = trash_profile("dup", NAMESPACE) + _save(tabs, "dup") + with pytest.raises(FileExistsError): + restore_trashed_profile(entry.token, NAMESPACE) + + +def test_read_only_profiles_cannot_be_trashed(make_tabs, isolate_profile_storage): + tabs = make_tabs() + _bundle(tabs, isolate_profile_storage, "bundled") + assert trash_profile("bundled", NAMESPACE) is None + assert "bundled" in list_profiles(NAMESPACE) + + +def test_copy_profile_and_notes(make_tabs): + tabs = make_tabs() + _save(tabs, "src") + set_profile_notes("src", "my notes", NAMESPACE) + copy_profile("src", "dst", NAMESPACE) + assert profile_origin("dst", NAMESPACE) == "settings" + assert get_profile_info("dst", NAMESPACE).notes == "my notes" + with pytest.raises(FileExistsError): + copy_profile("src", "dst", NAMESPACE) + set_profile_notes("src", "", NAMESPACE) + assert get_profile_info("src", NAMESPACE).notes == "" + + +################################################################################ +# Library controller +################################################################################ + + +def test_library_sections_and_flags(make_tabs, isolate_profile_storage): + tabs = make_tabs() + _bundle(tabs, isolate_profile_storage, "shipped") + _save(tabs, "mine") + _save(tabs, "gone") + tabs.current_dock_area()._current_profile_name = None + trash_profile("gone", NAMESPACE) + _save(tabs, "mine") + controller = ProfileLibraryController(ProfileActions(tabs.current_dock_area())) + sections = {row["name"]: row["section"] for row in controller.state.rows} + assert sections == {"mine": SECTION_MINE, "shipped": SECTION_BUNDLED, "gone": SECTION_TRASH} + selected = controller.state.selected + assert selected["name"] == "mine" and selected["isCurrent"] + assert not selected["canDelete"] and "Close the tab" in selected["deleteReason"] + + +def test_library_delete_and_undo(make_tabs): + tabs = make_tabs() + _save(tabs, "closed") + tabs.new_workspace() + _save(tabs, "open_one") + tabs.close_workspace(0) + controller = ProfileLibraryController(ProfileActions(tabs.current_dock_area())) + controller.select("p:closed") + controller.request_delete() + assert controller.state.pending_delete == "closed" + controller.confirm_delete() + assert "closed" not in list_profiles(NAMESPACE) + assert controller.state.banner.action == "Undo" + assert any(row["kind"] == "trash" for row in controller.state.rows) + controller.banner_action() + assert "closed" in list_profiles(NAMESPACE) + assert not any(row["kind"] == "trash" for row in controller.state.rows) + + +def test_library_search_and_pin(make_tabs): + tabs = make_tabs() + _save(tabs, "alpha") + _save(tabs, "beta") + set_profile_notes("beta", "tomography setup", NAMESPACE) + controller = ProfileLibraryController(ProfileActions(tabs.current_dock_area())) + controller.set_query("tomo") + assert [row["name"] for row in controller.state.rows] == ["beta"] + assert controller.state.selected["name"] == "beta" + controller.set_query("zzz") + assert controller.state.rows == [] and "zzz" in controller.state.empty_text + controller.set_query("") + assert is_quick_select("alpha", NAMESPACE) + controller.toggle_pin("p:alpha") + assert not is_quick_select("alpha", NAMESPACE) + + +def test_library_open_in_new_tab_and_here(make_tabs): + tabs = make_tabs() + _save(tabs, "one") + tabs.new_workspace() + _save(tabs, "two") + tabs.close_workspace(1) + controller = ProfileLibraryController(ProfileActions(tabs.current_dock_area())) + controller.select("p:two") + controller.open_selected(new_tab=True) + assert tabs.workspace_names() == ["one", "two"] + assert tabs.currentIndex() == 1 + tabs.close_workspace(1) + controller.refresh() + controller.select("p:two") + controller.open_selected(new_tab=False) + assert tabs.workspace_names() == ["two"] + + +def test_library_rename_keeps_or_moves_old_profile(make_tabs): + tabs = make_tabs() + _save(tabs, "first") + controller = ProfileLibraryController(ProfileActions(tabs.current_dock_area())) + assert controller.apply_name("rename", "first", "second") + assert tabs.workspace_names() == ["second"] + assert {"first", "second"} <= set(list_profiles(NAMESPACE)) + POLICY.rename_keeps_old_profile = False + assert controller.apply_name("rename", "second", "third") + assert tabs.workspace_names() == ["third"] + assert "second" not in list_profiles(NAMESPACE) + assert "second" in [t.name for t in list_trashed_profiles(NAMESPACE)] + + +def test_library_rename_closed_profile_copies_it(make_tabs): + tabs = make_tabs() + _save(tabs, "closed") + tabs.current_dock_area()._current_profile_name = None + controller = ProfileLibraryController(ProfileActions(tabs.current_dock_area())) + assert controller.apply_name("rename", "closed", "renamed") + assert "renamed" in list_profiles(NAMESPACE) + + +def test_library_duplicate_and_replace_goes_to_trash(make_tabs): + tabs = make_tabs() + _save(tabs, "base") + _save(tabs, "target") + _save(tabs, "base") + actions = ProfileActions(tabs.current_dock_area()) + text = actions.duplicate("base", "target") + assert "Recently deleted" in text + assert [t.name for t in list_trashed_profiles(NAMESPACE)] == ["target"] + assert "target" in list_profiles(NAMESPACE) + + +def test_library_revert(make_tabs): + tabs = make_tabs() + dock_area = tabs.current_dock_area() + dock_area.new(WIDGET) + _save(tabs, "rev") + dock_area.new(WIDGET) + assert len(dock_area.dock_list()) == 2 + controller = ProfileLibraryController(ProfileActions(dock_area)) + assert controller.state.selected["canRevert"] + controller.apply_revert("rev") + assert len(tabs.current_dock_area().dock_list()) == 1 + + +################################################################################ +# Views and wiring +################################################################################ + + +@pytest.mark.parametrize("ui", ["qwidget", "qml"]) +def test_library_window_opens_from_toolbar(make_tabs, qtbot, ui): + tabs = make_tabs(ui) + _save(tabs, "shown") + dock_area = tabs.current_dock_area() + dock_area.show_workspace_manager() + dialog = dock_area.manage_dialog + assert dialog is not None and dialog.isVisible() + assert dialog.controller.state.selected["name"] == "shown" + if ui == "qml": + view = dialog.view + assert view.status() == QQuickWidget.Status.Ready, view.errors() + assert dialog.backend.rows.rowCount() == 1 + else: + assert dialog.library.name_label.text() == "shown" + dialog.close() + qtbot.waitUntil(lambda: dock_area.manage_dialog is None) + + +@pytest.mark.parametrize("ui", ["qwidget", "qml"]) +def test_toolbar_save_uses_name_dialog(make_tabs, monkeypatch, ui): + tabs = make_tabs(ui) + cls = profile_qml.ProfileNameDialog if ui == "qml" else profile_qwidget.ProfileNameDialog + _accept_with(monkeypatch, cls, "from_dialog") + tabs.current_dock_area().save_profile_dialog() + assert tabs.workspace_names() == ["from_dialog"] + + +@pytest.mark.parametrize("ui", ["qwidget", "qml"]) +def test_name_dialog_blocks_read_only_names(make_tabs, isolate_profile_storage, monkeypatch, ui): + tabs = make_tabs(ui) + _bundle(tabs, isolate_profile_storage, "shipped") + cls = profile_qml.ProfileNameDialog if ui == "qml" else profile_qwidget.ProfileNameDialog + _accept_with(monkeypatch, cls, "shipped") + assert ask_profile_name(tabs.current_dock_area(), "save") is None + assert tabs.workspace_names() == [None] + + +def test_qwidget_name_dialog_states(make_tabs, qtbot): + tabs = make_tabs() + _save(tabs, "taken") + tabs.current_dock_area()._current_profile_name = None + dialog = profile_qwidget.ProfileNameDialog(ProfileActions(tabs.current_dock_area()), "save") + qtbot.addWidget(dialog) + assert not dialog.confirm_button.isEnabled() + dialog.name_edit.setText("taken") + assert dialog.confirm_button.isEnabled() and dialog.confirm_button.text() == "Replace" + dialog.name_edit.setText("bad:name") + assert not dialog.confirm_button.isEnabled() + dialog.name_edit.setText("fresh") + assert dialog.confirm_button.text() == "Save" + + +def test_tab_rename_uses_name_dialog(make_tabs, monkeypatch): + tabs = make_tabs() + _save(tabs, "tab_old") + _accept_with(monkeypatch, profile_qwidget.ProfileNameDialog, "tab_new") + assert tabs.rename_workspace(0) + assert tabs.workspace_names() == ["tab_new"] + + +def test_revert_from_toolbar_asks_with_revert_dialog(make_tabs, monkeypatch): + tabs = make_tabs() + dock_area = tabs.current_dock_area() + _save(tabs, "rev") + dock_area.new(WIDGET) + asked = [] + monkeypatch.setattr( + profile_qwidget.RevertProfileDialog, + "exec", + lambda self: asked.append(True) or QDialog.DialogCode.Rejected, + ) + dock_area.restore_baseline_profile(show_dialog=True) + assert asked and len(dock_area.dock_list()) == 1 + + +def test_one_tab_policy_can_be_relaxed(make_tabs): + tabs = make_tabs() + _save(tabs, "shared") + tabs.new_workspace() + page = tabs.widget(1) + assert tabs._is_open_elsewhere("shared", page) + POLICY.one_tab_per_profile = False + assert not tabs._is_open_elsewhere("shared", page) + + +def test_library_created_directly(make_tabs, qtbot): + tabs = make_tabs() + dialog = create_profile_library(tabs.current_dock_area(), ui="qml") + qtbot.addWidget(dialog) + assert dialog.controller.state.empty_text + dialog.close()