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