feat(main_app): open workspaces as tabs in the dock area view

This commit is contained in:
Claude committed 2026-10-09 20:26:31 +00:00
1 parent 703513b175
commit 1a65152554
6 files changed
+868 -11

No files matched your search

@@ -1,15 +1,20 @@
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.view import ViewBase
from bec_widgets.applications.views.view import ViewBase, ViewTourSteps
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.dock_area.workspace_tabs import WorkspaceTabs
class DockAreaView(ViewBase):
"""
Modular dock area view for arranging and managing multiple dockable widgets.
Each workspace lives in its own tab with its own dock area and layout. RPC calls are
forwarded to the dock area of the current tab.
"""
RPC_CONTENT_CLASS = BECDockArea
RPC_CONTENT_ATTR = "dock_area"
def __init__(
self,
@@ -21,11 +26,36 @@ class DockAreaView(ViewBase):
**kwargs,
):
super().__init__(parent=parent, content=content, view_id=view_id, title=title, **kwargs)
self.dock_area = BECDockArea(
self,
profile_namespace="bec",
auto_profile_namespace=False,
object_name="DockArea",
rpc_exposed=False,
self.workspaces = WorkspaceTabs(self, profile_namespace="bec", rpc_exposed=False)
self.set_content(self.workspaces)
@property
def dock_area(self) -> BECDockArea | None:
"""The dock area of the current workspace tab."""
return self.workspaces.current_dock_area()
def register_tour_steps(self, guided_tour, main_app):
"""Register the current dock area's components with the guided tour."""
tabs = self.workspaces
def get_workspace_tabs():
main_app.set_current("dock_area")
return (tabs.tabBar(), None)
step_id = guided_tour.register_widget(
widget=get_workspace_tabs,
title="Workspace Tabs",
text="Each tab is its own workspace with its own layout. Use + to open a new or "
"saved workspace, double-click a tab to rename it, and drag tabs to reorder them. "
"Open tabs are restored on the next start.",
)
self.set_content(self.dock_area)
dock_area = self.dock_area
view_tour = dock_area.register_tour_steps(guided_tour, main_app) if dock_area else None
if view_tour is None:
return ViewTourSteps(view_title="Dock Area Workspace", step_ids=[step_id])
view_tour.step_ids.insert(0, step_id)
return view_tour
def cleanup(self):
self.workspaces.cleanup()
super().cleanup()
@@ -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):
@@ -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.
@@ -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))
+14 -1
View File
@@ -151,7 +151,8 @@ def test_view_switch_method_switches_to_target(app_with_spies, qtbot):
def test_view_content_widget_is_hidden_from_namespace(app_with_spies):
app, _, _, _ = app_with_spies
assert app.dock_area.content is app.dock_area.dock_area
assert app.dock_area.content is app.dock_area.workspaces
assert app.dock_area.dock_area is app.dock_area.workspaces.current_dock_area()
def test_developer_view_is_experimental_by_default(monkeypatch, tmp_path, qtbot, mocked_client):
@@ -305,3 +306,15 @@ def test_guided_tour_can_start_and_stop(app_with_spies, qtbot):
# Check that tour is stopped
assert not app.guided_tour._active
def test_dock_area_view_rpc_targets_current_workspace_tab(app_with_spies):
app, _, _, _ = app_with_spies
view = app.dock_area
first = view.dock_area
view.workspaces.new_workspace()
second = getattr(view, type(view).RPC_CONTENT_ATTR)
assert second is view.workspaces.current_dock_area()
assert second is not first
view.workspaces.setCurrentIndex(0)
assert getattr(view, type(view).RPC_CONTENT_ATTR) is first
+269
View File
@@ -0,0 +1,269 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, redefined-outer-name
import pytest
from qtpy.QtWidgets import QInputDialog, QMessageBox
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.containers.dock_area.profile_utils import (
get_open_workspaces,
list_profiles,
set_open_workspaces,
)
from bec_widgets.widgets.containers.dock_area.workspace_tabs import (
UNTITLED_WORKSPACE,
WorkspaceTabs,
)
NAMESPACE = "tabs_test"
@pytest.fixture(autouse=True)
def isolate_profile_storage(tmp_path, monkeypatch):
root = tmp_path / "profiles_root"
root.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(root))
yield
@pytest.fixture
def make_tabs(qtbot, mocked_client):
created = []
def _make(**kwargs):
tabs = WorkspaceTabs(profile_namespace=NAMESPACE, client=mocked_client, **kwargs)
qtbot.addWidget(tabs)
tabs.show()
qtbot.waitExposed(tabs)
created.append(tabs)
return tabs
yield _make
for tabs in created:
tabs.cleanup()
def _save_profile(tabs, name):
"""Save the current tab under *name* without dialogs."""
tabs.current_dock_area().save_profile(name, show_dialog=False)
def test_starts_with_one_untitled_workspace(make_tabs):
tabs = make_tabs()
assert tabs.count() == 1
assert tabs.tabText(0) == UNTITLED_WORKSPACE
assert tabs.current_dock_area() is not None
assert tabs.workspace_names() == [None]
def test_new_workspace_adds_and_switches_tab(make_tabs):
tabs = make_tabs()
first = tabs.current_dock_area()
tabs.new_workspace()
assert tabs.count() == 2
assert tabs.currentIndex() == 1
second = tabs.current_dock_area()
assert second is not first
assert tabs.dock_areas() == [first, second]
def test_each_tab_keeps_its_own_layout(make_tabs):
tabs = make_tabs()
tabs.current_dock_area().new("RingProgressBar")
tabs.new_workspace()
assert tabs.current_dock_area().dock_list() == []
tabs.setCurrentIndex(0)
assert len(tabs.current_dock_area().dock_list()) == 1
def test_saving_names_the_tab_and_persists_open_tabs(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
assert tabs.tabText(0) == "alignment"
tabs.new_workspace()
_save_profile(tabs, "scans")
assert get_open_workspaces(NAMESPACE) == (["alignment", "scans"], "scans")
tabs.setCurrentIndex(0)
assert get_open_workspaces(NAMESPACE) == (["alignment", "scans"], "alignment")
def test_unsaved_tabs_are_not_persisted(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
assert get_open_workspaces(NAMESPACE) == (["alignment"], None)
def test_restores_open_tabs_lazily(make_tabs):
tabs = make_tabs()
tabs.current_dock_area().new("RingProgressBar")
_save_profile(tabs, "alignment")
tabs.new_workspace()
_save_profile(tabs, "scans")
tabs.setCurrentIndex(0)
tabs.cleanup()
restored = make_tabs()
assert [restored.tabText(i) for i in range(restored.count())] == ["alignment", "scans"]
assert restored.currentIndex() == 0
# Only the active tab builds its dock area at startup
assert [page.is_materialized for page in restored.pages()] == [True, False]
assert len(restored.current_dock_area().dock_list()) == 1
restored.setCurrentIndex(1)
assert restored.pages()[1].is_materialized
assert restored.current_dock_area()._current_profile_name == "scans"
def test_restore_skips_missing_profiles(make_tabs):
set_open_workspaces(["gone", "also_gone"], "gone", namespace=NAMESPACE)
tabs = make_tabs()
assert tabs.count() == 1
assert tabs.workspace_names() == [None]
def test_restore_is_scoped_to_known_profiles(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.cleanup()
set_open_workspaces(["alignment", "gone"], "gone", namespace=NAMESPACE)
restored = make_tabs()
assert restored.workspace_names() == ["alignment"]
def test_open_workspace_switches_to_existing_tab(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
tabs.open_workspace("alignment")
assert tabs.count() == 2
assert tabs.currentIndex() == 0
def test_open_workspace_opens_saved_profile_in_new_tab(make_tabs):
tabs = make_tabs()
tabs.current_dock_area().new("RingProgressBar")
_save_profile(tabs, "alignment")
tabs.new_workspace()
_save_profile(tabs, "scans")
tabs.close_workspace(0)
assert tabs.workspace_names() == ["scans"]
tabs.open_workspace("alignment")
assert tabs.workspace_names() == ["scans", "alignment"]
assert len(tabs.current_dock_area().dock_list()) == 1
def test_loading_profile_open_in_other_tab_switches_instead(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
_save_profile(tabs, "scans")
tabs.current_dock_area().load_profile("alignment")
assert tabs.currentIndex() == 0
assert tabs.workspace_names() == ["alignment", "scans"]
def test_saving_under_name_open_in_other_tab_is_refused(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
_save_profile(tabs, "alignment")
assert tabs.workspace_names() == ["alignment", None]
def test_close_workspace_keeps_profile_on_disk(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
_save_profile(tabs, "scans")
assert tabs.close_workspace(0)
assert tabs.workspace_names() == ["scans"]
assert "alignment" in list_profiles(NAMESPACE)
assert get_open_workspaces(NAMESPACE) == (["scans"], "scans")
def test_closing_last_tab_leaves_empty_workspace(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.close_workspace(0)
assert tabs.count() == 1
assert tabs.workspace_names() == [None]
assert "alignment" in list_profiles(NAMESPACE)
def test_close_unsaved_workspace_with_widgets_asks_first(make_tabs, monkeypatch):
tabs = make_tabs()
tabs.current_dock_area().new("RingProgressBar")
tabs.new_workspace()
monkeypatch.setattr(
QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.No
)
assert not tabs.close_workspace(0)
assert tabs.count() == 2
monkeypatch.setattr(
QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.Yes
)
assert tabs.close_workspace(0)
assert tabs.count() == 1
def test_rename_saves_under_new_name_and_keeps_old_profile(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
assert tabs.rename_workspace(0, "alignment_v2")
assert tabs.tabText(0) == "alignment_v2"
profiles = list_profiles(NAMESPACE)
assert "alignment" in profiles and "alignment_v2" in profiles
assert get_open_workspaces(NAMESPACE) == (["alignment_v2"], "alignment_v2")
def test_rename_untitled_workspace_saves_it(make_tabs, monkeypatch):
tabs = make_tabs()
monkeypatch.setattr(QInputDialog, "getText", lambda *args, **kwargs: ("beamtime", True))
assert tabs.rename_workspace()
assert tabs.tabText(0) == "beamtime"
assert "beamtime" in list_profiles(NAMESPACE)
def test_rename_to_name_open_in_other_tab_is_refused(make_tabs, monkeypatch):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
infos = []
monkeypatch.setattr(QMessageBox, "information", lambda *args, **kwargs: infos.append(args))
assert not tabs.rename_workspace(1, "alignment")
assert infos
assert tabs.workspace_names() == ["alignment", None]
def test_rename_over_existing_saved_profile_asks_first(make_tabs, monkeypatch):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.close_workspace(0)
monkeypatch.setattr(
QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.No
)
assert not tabs.rename_workspace(0, "alignment")
assert tabs.workspace_names() == [None]
def test_moving_tabs_updates_persisted_order(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
_save_profile(tabs, "scans")
tabs.tabBar().moveTab(1, 0)
assert get_open_workspaces(NAMESPACE)[0] == ["scans", "alignment"]
def test_open_menu_lists_saved_profiles(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs._populate_open_menu()
texts = [action.text() for action in tabs._open_menu.actions()]
assert "New Workspace" in texts
assert "alignment" in texts