mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-11-04 09:13:11 +01:00
feat(main_app): open workspaces as tabs in the dock area view
This commit is contained in:
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))
|
||||
@@ -151,7 +151,8 @@ def test_view_switch_method_switches_to_target(app_with_spies, qtbot):
|
||||
|
||||
def test_view_content_widget_is_hidden_from_namespace(app_with_spies):
|
||||
app, _, _, _ = app_with_spies
|
||||
assert app.dock_area.content is app.dock_area.dock_area
|
||||
assert app.dock_area.content is app.dock_area.workspaces
|
||||
assert app.dock_area.dock_area is app.dock_area.workspaces.current_dock_area()
|
||||
|
||||
|
||||
def test_developer_view_is_experimental_by_default(monkeypatch, tmp_path, qtbot, mocked_client):
|
||||
@@ -305,3 +306,15 @@ def test_guided_tour_can_start_and_stop(app_with_spies, qtbot):
|
||||
|
||||
# Check that tour is stopped
|
||||
assert not app.guided_tour._active
|
||||
|
||||
|
||||
def test_dock_area_view_rpc_targets_current_workspace_tab(app_with_spies):
|
||||
app, _, _, _ = app_with_spies
|
||||
view = app.dock_area
|
||||
first = view.dock_area
|
||||
view.workspaces.new_workspace()
|
||||
second = getattr(view, type(view).RPC_CONTENT_ATTR)
|
||||
assert second is view.workspaces.current_dock_area()
|
||||
assert second is not first
|
||||
view.workspaces.setCurrentIndex(0)
|
||||
assert getattr(view, type(view).RPC_CONTENT_ATTR) is first
|
||||
@@ -0,0 +1,269 @@
|
||||
# pylint: disable=missing-function-docstring, missing-module-docstring, redefined-outer-name
|
||||
import pytest
|
||||
from qtpy.QtWidgets import QInputDialog, QMessageBox
|
||||
|
||||
from bec_widgets.tests.client_mocks import mocked_client
|
||||
from bec_widgets.widgets.containers.dock_area.profile_utils import (
|
||||
get_open_workspaces,
|
||||
list_profiles,
|
||||
set_open_workspaces,
|
||||
)
|
||||
from bec_widgets.widgets.containers.dock_area.workspace_tabs import (
|
||||
UNTITLED_WORKSPACE,
|
||||
WorkspaceTabs,
|
||||
)
|
||||
|
||||
NAMESPACE = "tabs_test"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_profile_storage(tmp_path, monkeypatch):
|
||||
root = tmp_path / "profiles_root"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(root))
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_tabs(qtbot, mocked_client):
|
||||
created = []
|
||||
|
||||
def _make(**kwargs):
|
||||
tabs = WorkspaceTabs(profile_namespace=NAMESPACE, client=mocked_client, **kwargs)
|
||||
qtbot.addWidget(tabs)
|
||||
tabs.show()
|
||||
qtbot.waitExposed(tabs)
|
||||
created.append(tabs)
|
||||
return tabs
|
||||
|
||||
yield _make
|
||||
for tabs in created:
|
||||
tabs.cleanup()
|
||||
|
||||
|
||||
def _save_profile(tabs, name):
|
||||
"""Save the current tab under *name* without dialogs."""
|
||||
tabs.current_dock_area().save_profile(name, show_dialog=False)
|
||||
|
||||
|
||||
def test_starts_with_one_untitled_workspace(make_tabs):
|
||||
tabs = make_tabs()
|
||||
assert tabs.count() == 1
|
||||
assert tabs.tabText(0) == UNTITLED_WORKSPACE
|
||||
assert tabs.current_dock_area() is not None
|
||||
assert tabs.workspace_names() == [None]
|
||||
|
||||
|
||||
def test_new_workspace_adds_and_switches_tab(make_tabs):
|
||||
tabs = make_tabs()
|
||||
first = tabs.current_dock_area()
|
||||
tabs.new_workspace()
|
||||
assert tabs.count() == 2
|
||||
assert tabs.currentIndex() == 1
|
||||
second = tabs.current_dock_area()
|
||||
assert second is not first
|
||||
assert tabs.dock_areas() == [first, second]
|
||||
|
||||
|
||||
def test_each_tab_keeps_its_own_layout(make_tabs):
|
||||
tabs = make_tabs()
|
||||
tabs.current_dock_area().new("RingProgressBar")
|
||||
tabs.new_workspace()
|
||||
assert tabs.current_dock_area().dock_list() == []
|
||||
tabs.setCurrentIndex(0)
|
||||
assert len(tabs.current_dock_area().dock_list()) == 1
|
||||
|
||||
|
||||
def test_saving_names_the_tab_and_persists_open_tabs(make_tabs):
|
||||
tabs = make_tabs()
|
||||
_save_profile(tabs, "alignment")
|
||||
assert tabs.tabText(0) == "alignment"
|
||||
tabs.new_workspace()
|
||||
_save_profile(tabs, "scans")
|
||||
assert get_open_workspaces(NAMESPACE) == (["alignment", "scans"], "scans")
|
||||
|
||||
tabs.setCurrentIndex(0)
|
||||
assert get_open_workspaces(NAMESPACE) == (["alignment", "scans"], "alignment")
|
||||
|
||||
|
||||
def test_unsaved_tabs_are_not_persisted(make_tabs):
|
||||
tabs = make_tabs()
|
||||
_save_profile(tabs, "alignment")
|
||||
tabs.new_workspace()
|
||||
assert get_open_workspaces(NAMESPACE) == (["alignment"], None)
|
||||
|
||||
|
||||
def test_restores_open_tabs_lazily(make_tabs):
|
||||
tabs = make_tabs()
|
||||
tabs.current_dock_area().new("RingProgressBar")
|
||||
_save_profile(tabs, "alignment")
|
||||
tabs.new_workspace()
|
||||
_save_profile(tabs, "scans")
|
||||
tabs.setCurrentIndex(0)
|
||||
tabs.cleanup()
|
||||
|
||||
restored = make_tabs()
|
||||
assert [restored.tabText(i) for i in range(restored.count())] == ["alignment", "scans"]
|
||||
assert restored.currentIndex() == 0
|
||||
# Only the active tab builds its dock area at startup
|
||||
assert [page.is_materialized for page in restored.pages()] == [True, False]
|
||||
assert len(restored.current_dock_area().dock_list()) == 1
|
||||
|
||||
restored.setCurrentIndex(1)
|
||||
assert restored.pages()[1].is_materialized
|
||||
assert restored.current_dock_area()._current_profile_name == "scans"
|
||||
|
||||
|
||||
def test_restore_skips_missing_profiles(make_tabs):
|
||||
set_open_workspaces(["gone", "also_gone"], "gone", namespace=NAMESPACE)
|
||||
tabs = make_tabs()
|
||||
assert tabs.count() == 1
|
||||
assert tabs.workspace_names() == [None]
|
||||
|
||||
|
||||
def test_restore_is_scoped_to_known_profiles(make_tabs):
|
||||
tabs = make_tabs()
|
||||
_save_profile(tabs, "alignment")
|
||||
tabs.cleanup()
|
||||
set_open_workspaces(["alignment", "gone"], "gone", namespace=NAMESPACE)
|
||||
|
||||
restored = make_tabs()
|
||||
assert restored.workspace_names() == ["alignment"]
|
||||
|
||||
|
||||
def test_open_workspace_switches_to_existing_tab(make_tabs):
|
||||
tabs = make_tabs()
|
||||
_save_profile(tabs, "alignment")
|
||||
tabs.new_workspace()
|
||||
tabs.open_workspace("alignment")
|
||||
assert tabs.count() == 2
|
||||
assert tabs.currentIndex() == 0
|
||||
|
||||
|
||||
def test_open_workspace_opens_saved_profile_in_new_tab(make_tabs):
|
||||
tabs = make_tabs()
|
||||
tabs.current_dock_area().new("RingProgressBar")
|
||||
_save_profile(tabs, "alignment")
|
||||
tabs.new_workspace()
|
||||
_save_profile(tabs, "scans")
|
||||
tabs.close_workspace(0)
|
||||
assert tabs.workspace_names() == ["scans"]
|
||||
|
||||
tabs.open_workspace("alignment")
|
||||
assert tabs.workspace_names() == ["scans", "alignment"]
|
||||
assert len(tabs.current_dock_area().dock_list()) == 1
|
||||
|
||||
|
||||
def test_loading_profile_open_in_other_tab_switches_instead(make_tabs):
|
||||
tabs = make_tabs()
|
||||
_save_profile(tabs, "alignment")
|
||||
tabs.new_workspace()
|
||||
_save_profile(tabs, "scans")
|
||||
|
||||
tabs.current_dock_area().load_profile("alignment")
|
||||
|
||||
assert tabs.currentIndex() == 0
|
||||
assert tabs.workspace_names() == ["alignment", "scans"]
|
||||
|
||||
|
||||
def test_saving_under_name_open_in_other_tab_is_refused(make_tabs):
|
||||
tabs = make_tabs()
|
||||
_save_profile(tabs, "alignment")
|
||||
tabs.new_workspace()
|
||||
_save_profile(tabs, "alignment")
|
||||
assert tabs.workspace_names() == ["alignment", None]
|
||||
|
||||
|
||||
def test_close_workspace_keeps_profile_on_disk(make_tabs):
|
||||
tabs = make_tabs()
|
||||
_save_profile(tabs, "alignment")
|
||||
tabs.new_workspace()
|
||||
_save_profile(tabs, "scans")
|
||||
assert tabs.close_workspace(0)
|
||||
assert tabs.workspace_names() == ["scans"]
|
||||
assert "alignment" in list_profiles(NAMESPACE)
|
||||
assert get_open_workspaces(NAMESPACE) == (["scans"], "scans")
|
||||
|
||||
|
||||
def test_closing_last_tab_leaves_empty_workspace(make_tabs):
|
||||
tabs = make_tabs()
|
||||
_save_profile(tabs, "alignment")
|
||||
tabs.close_workspace(0)
|
||||
assert tabs.count() == 1
|
||||
assert tabs.workspace_names() == [None]
|
||||
assert "alignment" in list_profiles(NAMESPACE)
|
||||
|
||||
|
||||
def test_close_unsaved_workspace_with_widgets_asks_first(make_tabs, monkeypatch):
|
||||
tabs = make_tabs()
|
||||
tabs.current_dock_area().new("RingProgressBar")
|
||||
tabs.new_workspace()
|
||||
monkeypatch.setattr(
|
||||
QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.No
|
||||
)
|
||||
assert not tabs.close_workspace(0)
|
||||
assert tabs.count() == 2
|
||||
|
||||
monkeypatch.setattr(
|
||||
QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.Yes
|
||||
)
|
||||
assert tabs.close_workspace(0)
|
||||
assert tabs.count() == 1
|
||||
|
||||
|
||||
def test_rename_saves_under_new_name_and_keeps_old_profile(make_tabs):
|
||||
tabs = make_tabs()
|
||||
_save_profile(tabs, "alignment")
|
||||
assert tabs.rename_workspace(0, "alignment_v2")
|
||||
assert tabs.tabText(0) == "alignment_v2"
|
||||
profiles = list_profiles(NAMESPACE)
|
||||
assert "alignment" in profiles and "alignment_v2" in profiles
|
||||
assert get_open_workspaces(NAMESPACE) == (["alignment_v2"], "alignment_v2")
|
||||
|
||||
|
||||
def test_rename_untitled_workspace_saves_it(make_tabs, monkeypatch):
|
||||
tabs = make_tabs()
|
||||
monkeypatch.setattr(QInputDialog, "getText", lambda *args, **kwargs: ("beamtime", True))
|
||||
assert tabs.rename_workspace()
|
||||
assert tabs.tabText(0) == "beamtime"
|
||||
assert "beamtime" in list_profiles(NAMESPACE)
|
||||
|
||||
|
||||
def test_rename_to_name_open_in_other_tab_is_refused(make_tabs, monkeypatch):
|
||||
tabs = make_tabs()
|
||||
_save_profile(tabs, "alignment")
|
||||
tabs.new_workspace()
|
||||
infos = []
|
||||
monkeypatch.setattr(QMessageBox, "information", lambda *args, **kwargs: infos.append(args))
|
||||
assert not tabs.rename_workspace(1, "alignment")
|
||||
assert infos
|
||||
assert tabs.workspace_names() == ["alignment", None]
|
||||
|
||||
|
||||
def test_rename_over_existing_saved_profile_asks_first(make_tabs, monkeypatch):
|
||||
tabs = make_tabs()
|
||||
_save_profile(tabs, "alignment")
|
||||
tabs.close_workspace(0)
|
||||
monkeypatch.setattr(
|
||||
QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.No
|
||||
)
|
||||
assert not tabs.rename_workspace(0, "alignment")
|
||||
assert tabs.workspace_names() == [None]
|
||||
|
||||
|
||||
def test_moving_tabs_updates_persisted_order(make_tabs):
|
||||
tabs = make_tabs()
|
||||
_save_profile(tabs, "alignment")
|
||||
tabs.new_workspace()
|
||||
_save_profile(tabs, "scans")
|
||||
tabs.tabBar().moveTab(1, 0)
|
||||
assert get_open_workspaces(NAMESPACE)[0] == ["scans", "alignment"]
|
||||
|
||||
|
||||
def test_open_menu_lists_saved_profiles(make_tabs):
|
||||
tabs = make_tabs()
|
||||
_save_profile(tabs, "alignment")
|
||||
tabs._populate_open_menu()
|
||||
texts = [action.text() for action in tabs._open_menu.actions()]
|
||||
assert "New Workspace" in texts
|
||||
assert "alignment" in texts
|
||||
Reference in new issue
Block a user