1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-01-01 11:31:19 +01:00

feat(advanced_dock_area): UI/UX for profile management improved, saving directories logic adjusted

This commit is contained in:
2025-10-06 13:37:52 +02:00
parent 8c9d06e9d6
commit 4ad8b7cb22
8 changed files with 2286 additions and 558 deletions

View File

@@ -4,18 +4,15 @@ import os
from typing import Literal, cast
import PySide6QtAds as QtAds
from bec_lib import bec_logger
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Signal
from qtpy.QtCore import QTimer, Signal
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QDialog,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
@@ -37,14 +34,30 @@ from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.widget_state_manager import WidgetStateManager
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
SETTINGS_KEYS,
is_profile_readonly,
list_profiles,
open_settings,
profile_path,
default_profile_path,
get_last_profile,
is_quick_select,
load_default_profile_screenshot,
load_user_profile_screenshot,
now_iso_utc,
open_default_settings,
open_user_settings,
profile_origin,
profile_origin_display,
read_manifest,
set_profile_readonly,
restore_user_from_default,
set_last_profile,
set_quick_select,
user_profile_path,
write_manifest,
)
from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import (
RestoreProfileDialog,
SaveProfileDialog,
)
from bec_widgets.widgets.containers.advanced_dock_area.settings.workspace_manager import (
WorkSpaceManager,
)
from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.workspace_actions import (
WorkspaceConnection,
workspace_bundle,
@@ -65,6 +78,8 @@ from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatus
from bec_widgets.widgets.utility.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
logger = bec_logger.logger
class DockSettingsDialog(QDialog):
@@ -79,62 +94,6 @@ class DockSettingsDialog(QDialog):
layout.addWidget(self.prop_editor)
class SaveProfileDialog(QDialog):
"""Dialog for saving workspace profiles with read-only option."""
def __init__(self, parent: QWidget, current_name: str = ""):
super().__init__(parent)
self.setWindowTitle("Save Workspace Profile")
self.setModal(True)
self.resize(400, 150)
layout = QVBoxLayout(self)
# Name input
name_row = QHBoxLayout()
name_row.addWidget(QLabel("Profile Name:"))
self.name_edit = QLineEdit(current_name)
self.name_edit.setPlaceholderText("Enter profile name...")
name_row.addWidget(self.name_edit)
layout.addLayout(name_row)
# Read-only checkbox
self.readonly_checkbox = QCheckBox("Mark as read-only (cannot be overwritten or deleted)")
layout.addWidget(self.readonly_checkbox)
# Info label
info_label = QLabel("Read-only profiles are protected from modification and deletion.")
info_label.setStyleSheet("color: gray; font-size: 10px;")
layout.addWidget(info_label)
# Buttons
btn_row = QHBoxLayout()
btn_row.addStretch(1)
self.save_btn = QPushButton("Save")
self.save_btn.setDefault(True)
cancel_btn = QPushButton("Cancel")
self.save_btn.clicked.connect(self.accept)
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(self.save_btn)
btn_row.addWidget(cancel_btn)
layout.addLayout(btn_row)
# Enable/disable save button based on name input
self.name_edit.textChanged.connect(self._update_save_button)
self._update_save_button()
def _update_save_button(self):
"""Enable save button only when name is not empty."""
self.save_btn.setEnabled(bool(self.name_edit.text().strip()))
def get_profile_name(self) -> str:
"""Get the entered profile name."""
return self.name_edit.text().strip()
def is_readonly(self) -> bool:
"""Check if the profile should be marked as read-only."""
return self.readonly_checkbox.isChecked()
class AdvancedDockArea(BECWidget, QWidget):
RPC = True
PLUGIN = False
@@ -151,6 +110,7 @@ class AdvancedDockArea(BECWidget, QWidget):
# Define a signal for mode changes
mode_changed = Signal(str)
profile_changed = Signal(str)
def __init__(
self,
@@ -190,12 +150,18 @@ class AdvancedDockArea(BECWidget, QWidget):
self._setup_toolbar()
self._hook_toolbar()
# Popups
self.save_dialog = None
self.manage_dialog = None
# Place toolbar and dock manager into layout
self._root_layout.addWidget(self.toolbar)
self._root_layout.addWidget(self.dock_manager, 1)
# Populate and hook the workspace combo
self._refresh_workspace_list()
self._current_profile_name = None
self._pending_autosave_skip: tuple[str, str] | None = None
# State manager
self.state_manager = WidgetStateManager(self)
@@ -205,12 +171,29 @@ class AdvancedDockArea(BECWidget, QWidget):
# Initialize default editable state based on current lock
self._set_editable(True) # default to editable; will sync toolbar toggle below
# Sync Developer toggle icon state after initial setup
dev_action = self.toolbar.components.get_action("developer_mode").action
dev_action.setChecked(self._editable)
# Sync Developer toggle icon state after initial setup #TODO temporary disable
# dev_action = self.toolbar.components.get_action("developer_mode").action
# dev_action.setChecked(self._editable)
# Apply the requested mode after everything is set up
self.mode = mode
QTimer.singleShot(
0, self._fetch_initial_profile
) # To allow full init before loading profile and prevent segfault on exit
def _fetch_initial_profile(self):
# Restore last-used profile if available; otherwise fall back to combo selection
combo = self.toolbar.components.get_action("workspace_combo").widget
last = get_last_profile()
if last and (
os.path.exists(user_profile_path(last)) or os.path.exists(default_profile_path(last))
):
init_profile = last
else:
init_profile = combo.currentText()
if init_profile:
self.load_profile(init_profile)
combo.setCurrentText(init_profile)
def _make_dock(
self,
@@ -408,18 +391,18 @@ class AdvancedDockArea(BECWidget, QWidget):
self.toolbar.components.add_safe(
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False, parent=self)
)
# Developer mode toggle (moved from menu into toolbar)
self.toolbar.components.add_safe(
"developer_mode",
MaterialIconAction(
icon_name="code", tooltip="Developer Mode", checkable=True, parent=self
),
)
# Developer mode toggle (moved from menu into toolbar) #TODO temporary disable
# self.toolbar.components.add_safe(
# "developer_mode",
# MaterialIconAction(
# icon_name="code", tooltip="Developer Mode", checkable=True, parent=self
# ),
# )
bda = ToolbarBundle("dock_actions", self.toolbar.components)
bda.add_action("attach_all")
bda.add_action("screenshot")
bda.add_action("dark_mode")
bda.add_action("developer_mode")
# bda.add_action("developer_mode") #TODO temporary disable
self.toolbar.add_bundle(bda)
# Default bundle configuration (show menus by default)
@@ -485,20 +468,15 @@ class AdvancedDockArea(BECWidget, QWidget):
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
# Developer mode toggle
self.toolbar.components.get_action("developer_mode").action.toggled.connect(
self._on_developer_mode_toggled
)
# Developer mode toggle #TODO temporary disable
# self.toolbar.components.get_action("developer_mode").action.toggled.connect(
# self._on_developer_mode_toggled
# )
def _set_editable(self, editable: bool) -> None:
self.lock_workspace = not editable
self._editable = editable
# Sync the toolbar lock toggle with current mode
lock_action = self.toolbar.components.get_action("lock").action
lock_action.setChecked(not editable)
lock_action.setVisible(editable)
attach_all_action = self.toolbar.components.get_action("attach_all").action
attach_all_action.setVisible(editable)
@@ -517,8 +495,8 @@ class AdvancedDockArea(BECWidget, QWidget):
else:
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
# Keep Developer mode UI in sync
self.toolbar.components.get_action("developer_mode").action.setChecked(editable)
# Keep Developer mode UI in sync #TODO temporary disable
# self.toolbar.components.get_action("developer_mode").action.setChecked(editable)
def _on_developer_mode_toggled(self, checked: bool) -> None:
"""Handle developer mode checkbox toggle."""
@@ -689,63 +667,64 @@ class AdvancedDockArea(BECWidget, QWidget):
self._locked = value
self._apply_dock_lock(value)
self.toolbar.components.get_action("save_workspace").action.setVisible(not value)
self.toolbar.components.get_action("delete_workspace").action.setVisible(not value)
for dock in self.dock_list():
dock.setting_action.setVisible(not value)
def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None:
settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry())
settings.setValue(SETTINGS_KEYS["state"], b"")
settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState())
self.dock_manager.addPerspective(self.windowTitle())
self.dock_manager.savePerspectives(settings)
self.state_manager.save_state(settings=settings)
write_manifest(settings, self.dock_list())
if save_preview:
ba = self.screenshot_bytes()
if ba and len(ba) > 0:
settings.setValue(SETTINGS_KEYS["screenshot"], ba)
settings.setValue(SETTINGS_KEYS["screenshot_at"], now_iso_utc())
logger.info(f"Workspace snapshot written to settings: {settings.fileName()}")
@SafeSlot(str)
def save_profile(self, name: str | None = None):
"""
Save the current workspace profile.
On first save of a given name:
- writes a default copy to states/default/<name>.ini with tag=default and created_at
- writes a user copy to states/user/<name>.ini with tag=user and created_at
On subsequent saves of user-owned profiles:
- updates both the default and user copies so restore uses the latest snapshot.
Read-only bundled profiles cannot be overwritten.
Args:
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
name (str | None): The name of the profile to save. If None, prompts the user.
"""
if not name:
# Use the new SaveProfileDialog instead of QInputDialog
dialog = SaveProfileDialog(self)
if dialog.exec() != QDialog.Accepted:
return
name = dialog.get_profile_name()
readonly = dialog.is_readonly()
# Check if profile already exists and is read-only
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
suggested_name = f"{name}_custom"
reply = QMessageBox.warning(
self,
"Read-only Profile",
f"The profile '{name}' is marked as read-only and cannot be overwritten.\n\n"
f"Would you like to save it with a different name?\n"
f"Suggested name: '{suggested_name}'",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes,
)
if reply == QMessageBox.Yes:
# Show dialog again with suggested name pre-filled
dialog = SaveProfileDialog(self, suggested_name)
if dialog.exec() != QDialog.Accepted:
return
name = dialog.get_profile_name()
readonly = dialog.is_readonly()
def _profile_exists(profile_name: str) -> bool:
return profile_origin(profile_name) != "unknown"
# Check again if the new name is also read-only (recursive protection)
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
return self.save_profile()
else:
return
else:
# If name is provided directly, assume not read-only unless already exists
readonly = False
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
QMessageBox.warning(
self,
"Read-only Profile",
f"The profile '{name}' is marked as read-only and cannot be overwritten.",
QMessageBox.Ok,
)
return
initial_name = name or ""
quickselect_default = is_quick_select(name) if name else False
current_profile = getattr(self, "_current_profile_name", "") or ""
dialog = SaveProfileDialog(
self,
current_name=initial_name,
current_profile_name=current_profile,
name_exists=_profile_exists,
profile_origin=profile_origin,
origin_label=profile_origin_display,
quick_select_checked=quickselect_default,
)
if dialog.exec() != QDialog.Accepted:
return
name = dialog.get_profile_name()
quickselect = dialog.is_quick_select()
origin_before_save = profile_origin(name)
overwrite_default = dialog.overwrite_existing and origin_before_save == "settings"
# Display saving placeholder
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
workspace_combo.blockSignals(True)
@@ -753,42 +732,75 @@ class AdvancedDockArea(BECWidget, QWidget):
workspace_combo.setCurrentIndex(0)
workspace_combo.blockSignals(False)
# Save the profile
settings = open_settings(name)
settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry())
settings.setValue(
SETTINGS_KEYS["state"], b""
) # No QMainWindow state; placeholder for backward compat
settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState())
self.dock_manager.addPerspective(name)
self.dock_manager.savePerspectives(settings)
self.state_manager.save_state(settings=settings)
write_manifest(settings, self.dock_list())
# Create or update default copy controlled by overwrite flag
should_write_default = overwrite_default or not os.path.exists(default_profile_path(name))
if should_write_default:
ds = open_default_settings(name)
self._write_snapshot_to_settings(ds)
if not ds.value(SETTINGS_KEYS["created_at"], ""):
ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
# Ensure new profiles are not quick-select by default
if not ds.value(SETTINGS_KEYS["is_quick_select"], None):
ds.setValue(SETTINGS_KEYS["is_quick_select"], False)
# Set read-only status if specified
if readonly:
set_profile_readonly(name, readonly)
# Always (over)write the user copy
us = open_user_settings(name)
self._write_snapshot_to_settings(us)
if not us.value(SETTINGS_KEYS["created_at"], ""):
us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
# Ensure new profiles are not quick-select by default (only if missing)
if not us.value(SETTINGS_KEYS["is_quick_select"], None):
us.setValue(SETTINGS_KEYS["is_quick_select"], False)
# set quick select
if quickselect:
set_quick_select(name, quickselect)
settings.sync()
self._refresh_workspace_list()
if current_profile and current_profile != name and not dialog.overwrite_existing:
self._pending_autosave_skip = (current_profile, name)
else:
self._pending_autosave_skip = None
workspace_combo.setCurrentText(name)
self._current_profile_name = name
self.profile_changed.emit(name)
set_last_profile(name)
combo = self.toolbar.components.get_action("workspace_combo").widget
combo.refresh_profiles(active_profile=name)
def load_profile(self, name: str | None = None):
"""
Load a workspace profile.
Args:
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
Before switching, persist the current profile to the user copy.
Prefer loading the user copy; fall back to the default copy.
"""
# FIXME this has to be tweaked
if not name:
if not name: # Gui fallback if the name is not provided
name, ok = QInputDialog.getText(
self, "Load Workspace", "Enter the name of the workspace profile to load:"
)
if not ok or not name:
return
settings = open_settings(name)
prev_name = getattr(self, "_current_profile_name", None)
skip_pair = getattr(self, "_pending_autosave_skip", None)
if prev_name and prev_name != name:
if skip_pair and skip_pair == (prev_name, name):
self._pending_autosave_skip = None
else:
us_prev = open_user_settings(prev_name)
self._write_snapshot_to_settings(us_prev, save_preview=False)
# Choose source settings: user first, else default
if os.path.exists(user_profile_path(name)):
settings = open_user_settings(name)
elif os.path.exists(default_profile_path(name)):
settings = open_default_settings(name)
else:
QMessageBox.warning(self, "Profile not found", f"Profile '{name}' not found.")
return
# Rebuild widgets and restore states
for item in read_manifest(settings):
obj_name = item["object_name"]
widget_class = item["widget_class"]
@@ -806,8 +818,6 @@ class AdvancedDockArea(BECWidget, QWidget):
geom = settings.value(SETTINGS_KEYS["geom"])
if geom:
self.restoreGeometry(geom)
# No window state for QWidget-based host; keep for backwards compat read
# window_state = settings.value(SETTINGS_KEYS["state"]) # ignored
dock_state = settings.value(SETTINGS_KEYS["ads_state"])
if dock_state:
self.dock_manager.restoreState(dock_state)
@@ -815,6 +825,42 @@ class AdvancedDockArea(BECWidget, QWidget):
self.state_manager.load_state(settings=settings)
self._set_editable(self._editable)
self._current_profile_name = name
self.profile_changed.emit(name)
set_last_profile(name)
combo = self.toolbar.components.get_action("workspace_combo").widget
combo.refresh_profiles(active_profile=name)
@SafeSlot()
@SafeSlot(str)
def restore_user_profile_from_default(self, name: str | None = None):
"""
Overwrite the user copy of *name* with the default baseline.
If *name* is None, target the currently active profile.
Args:
name (str | None): The name of the profile to restore. If None, uses the current profile.
"""
target = name or getattr(self, "_current_profile_name", None)
if not target:
return
current_pixmap = None
if self.isVisible():
current_pixmap = QPixmap()
ba = bytes(self.screenshot_bytes())
current_pixmap.loadFromData(ba)
if current_pixmap is None or current_pixmap.isNull():
current_pixmap = load_user_profile_screenshot(target)
default_pixmap = load_default_profile_screenshot(target)
if not RestoreProfileDialog.confirm(self, current_pixmap, default_pixmap):
return
restore_user_from_default(target)
self.delete_all()
self.load_profile(target)
@SafeSlot()
def delete_profile(self):
"""
@@ -825,17 +871,6 @@ class AdvancedDockArea(BECWidget, QWidget):
if not name:
return
# Check if profile is read-only
if is_profile_readonly(name):
QMessageBox.warning(
self,
"Read-only Profile",
f"The profile '{name}' is marked as read-only and cannot be deleted.\n\n"
f"Read-only profiles are protected from modification and deletion.",
QMessageBox.Ok,
)
return
# Confirm deletion for regular profiles
reply = QMessageBox.question(
self,
@@ -848,7 +883,7 @@ class AdvancedDockArea(BECWidget, QWidget):
if reply != QMessageBox.Yes:
return
file_path = profile_path(name)
file_path = user_profile_path(name)
try:
os.remove(file_path)
except FileNotFoundError:
@@ -860,15 +895,69 @@ class AdvancedDockArea(BECWidget, QWidget):
Populate the workspace combo box with all saved profile names (without .ini).
"""
combo = self.toolbar.components.get_action("workspace_combo").widget
active_profile = getattr(self, "_current_profile_name", None)
if hasattr(combo, "refresh_profiles"):
combo.refresh_profiles()
combo.refresh_profiles(active_profile)
else:
# Fallback for regular QComboBox
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
list_quick_profiles,
)
combo.blockSignals(True)
combo.clear()
combo.addItems(list_profiles())
quick_profiles = list_quick_profiles()
items = list(quick_profiles)
if active_profile and active_profile not in items:
items.insert(0, active_profile)
combo.addItems(items)
if active_profile:
idx = combo.findText(active_profile)
if idx >= 0:
combo.setCurrentIndex(idx)
if active_profile and active_profile not in quick_profiles:
combo.setToolTip("Active profile is not in quick select")
else:
combo.setToolTip("")
combo.blockSignals(False)
################################################################################
# Dialog Popups
################################################################################
@SafeSlot()
def show_workspace_manager(self):
"""
Show the workspace manager dialog.
"""
manage_action = self.toolbar.components.get_action("manage_workspaces").action
if self.manage_dialog is None or not self.manage_dialog.isVisible():
self.manage_widget = WorkSpaceManager(
self, target_widget=self, default_profile=self._current_profile_name
)
self.manage_dialog = QDialog(modal=False)
self.manage_dialog.setWindowTitle("Workspace Manager")
self.manage_dialog.setMinimumSize(1200, 500)
self.manage_dialog.layout = QVBoxLayout(self.manage_dialog)
self.manage_dialog.layout.addWidget(self.manage_widget)
self.manage_dialog.finished.connect(self._manage_dialog_closed)
self.manage_dialog.show()
self.manage_dialog.resize(300, 300)
manage_action.setChecked(True)
else:
# If already open, bring it to the front
self.manage_dialog.raise_()
self.manage_dialog.activateWindow()
manage_action.setChecked(True) # keep it toggle
def _manage_dialog_closed(self):
self.manage_widget.close()
self.manage_widget.deleteLater()
self.manage_dialog.deleteLater()
self.manage_dialog = None
self.toolbar.components.get_action("manage_workspaces").action.setChecked(False)
################################################################################
# Mode Switching
################################################################################
@@ -913,6 +1002,15 @@ class AdvancedDockArea(BECWidget, QWidget):
"""
Cleanup the dock area.
"""
# before cleanup save current profile (user copy)
name = getattr(self, "_current_profile_name", None)
if name:
us = open_user_settings(name)
self._write_snapshot_to_settings(us)
set_last_profile(name)
if self.manage_dialog is not None:
self.manage_dialog.reject()
self.manage_dialog = None
self.delete_all()
self.dark_mode_button.close()
self.dark_mode_button.deleteLater()
@@ -920,7 +1018,7 @@ class AdvancedDockArea(BECWidget, QWidget):
super().cleanup()
if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)

View File

@@ -1,21 +1,181 @@
import os
"""
Utilities for managing AdvancedDockArea profiles stored in INI files.
Policy:
- All created/modified profiles are stored under the BEC settings root: <base_path>/profiles/{default,user}
- Bundled read-only defaults are discovered in BW core states/default and plugin bec_widgets/profiles but never written to.
- Lookup order when reading: user → settings default → app or plugin bundled default.
"""
from __future__ import annotations
import os
import shutil
from functools import lru_cache
from pathlib import Path
from typing import Literal
from bec_lib.client import BECClient
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from pydantic import BaseModel, Field
from PySide6QtAds import CDockWidget
from qtpy.QtCore import QSettings
from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt
from qtpy.QtGui import QPixmap
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
_DEFAULT_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "default")
_USER_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "user")
ProfileOrigin = Literal["module", "plugin", "settings", "unknown"]
def profiles_dir() -> str:
path = os.environ.get("BECWIDGETS_PROFILE_DIR", _USER_PROFILES_DIR)
def module_profiles_dir() -> str:
"""Return the read-only module-bundled profiles directory (no writes here)."""
return os.path.join(MODULE_PATH, "containers", "advanced_dock_area", "profiles")
@lru_cache(maxsize=1)
def _plugin_repo_root() -> Path | None:
try:
return Path(plugin_repo_path())
except ValueError:
return None
@lru_cache(maxsize=1)
def _plugin_display_name() -> str | None:
repo_root = _plugin_repo_root()
if not repo_root:
return None
repo_name = repo_root.name
if repo_name:
return repo_name
try:
pkg = plugin_package_name()
except ValueError:
return None
return pkg.split(".")[0] if pkg else None
@lru_cache(maxsize=1)
def plugin_profiles_dir() -> str | None:
"""Return the read-only plugin-bundled profiles directory if available."""
repo_root = _plugin_repo_root()
if not repo_root:
return None
candidates = [repo_root.joinpath("bec_widgets", "profiles")]
try:
package_root = repo_root.joinpath(*plugin_package_name().split("."))
candidates.append(package_root.joinpath("bec_widgets", "profiles"))
except ValueError:
pass
for candidate in candidates:
if candidate.is_dir():
return str(candidate)
return None
def _settings_profiles_root() -> str:
"""Return the writable profiles root provided by BEC client (or env fallback)."""
client = BECClient()
bec_widgets_settings = client._service_config.config.get("bec_widgets_settings")
bec_widgets_setting_path = (
bec_widgets_settings.get("base_path") if bec_widgets_settings else None
)
default_path = os.path.join(bec_widgets_setting_path, "profiles")
root = os.environ.get("BECWIDGETS_PROFILE_DIR", default_path)
os.makedirs(root, exist_ok=True)
return root
def default_profiles_dir() -> str:
path = os.path.join(_settings_profiles_root(), "default")
os.makedirs(path, exist_ok=True)
return path
def profile_path(name: str) -> str:
return os.path.join(profiles_dir(), f"{name}.ini")
def user_profiles_dir() -> str:
path = os.path.join(_settings_profiles_root(), "user")
os.makedirs(path, exist_ok=True)
return path
def default_profile_path(name: str) -> str:
return os.path.join(default_profiles_dir(), f"{name}.ini")
def user_profile_path(name: str) -> str:
return os.path.join(user_profiles_dir(), f"{name}.ini")
def module_profile_path(name: str) -> str:
return os.path.join(module_profiles_dir(), f"{name}.ini")
def plugin_profile_path(name: str) -> str | None:
directory = plugin_profiles_dir()
if not directory:
return None
return os.path.join(directory, f"{name}.ini")
def profile_origin(name: str) -> ProfileOrigin:
"""
Determine where a profile originates from.
Returns:
ProfileOrigin: "module" for bundled BEC profiles, "plugin" for beamline plugin bundles,
"settings" for user-defined ones, and "unknown" if no backing files are found.
"""
if os.path.exists(module_profile_path(name)):
return "module"
plugin_path = plugin_profile_path(name)
if plugin_path and os.path.exists(plugin_path):
return "plugin"
if os.path.exists(user_profile_path(name)) or os.path.exists(default_profile_path(name)):
return "settings"
return "unknown"
def is_profile_read_only(name: str) -> bool:
"""Return True when the profile originates from bundled module or plugin directories."""
return profile_origin(name) in {"module", "plugin"}
def profile_origin_display(name: str) -> str | None:
"""Return a human-readable label for the profile's origin."""
origin = profile_origin(name)
if origin == "module":
return "BEC Widgets"
if origin == "plugin":
return _plugin_display_name()
if origin == "settings":
return "User"
return None
def delete_profile_files(name: str) -> bool:
"""
Delete the profile files from the writable settings directories.
Removes both the user and default copies (if they exist) and clears the last profile
metadata when applicable. Returns True when at least one file was removed.
"""
if is_profile_read_only(name):
return False
removed = False
for path in {user_profile_path(name), default_profile_path(name)}:
try:
os.remove(path)
removed = True
except FileNotFoundError:
continue
if removed and get_last_profile() == name:
set_last_profile(None)
return removed
SETTINGS_KEYS = {
@@ -23,29 +183,90 @@ SETTINGS_KEYS = {
"state": "mainWindow/State",
"ads_state": "mainWindow/DockingState",
"manifest": "manifest/widgets",
"readonly": "profile/readonly",
"created_at": "profile/created_at",
"is_quick_select": "profile/quick_select",
"screenshot": "profile/screenshot",
"screenshot_at": "profile/screenshot_at",
"last_profile": "app/last_profile",
}
def list_profiles() -> list[str]:
return sorted(os.path.splitext(f)[0] for f in os.listdir(profiles_dir()) if f.endswith(".ini"))
# Collect profiles from writable settings (default + user)
defaults = {
os.path.splitext(f)[0] for f in os.listdir(default_profiles_dir()) if f.endswith(".ini")
}
users = {os.path.splitext(f)[0] for f in os.listdir(user_profiles_dir()) if f.endswith(".ini")}
# Also consider read-only defaults from core module and beamline plugin repositories
read_only_sources: dict[str, tuple[str, str]] = {}
sources: list[tuple[str, str | None]] = [
("module", module_profiles_dir()),
("plugin", plugin_profiles_dir()),
]
for origin, directory in sources:
if not directory or not os.path.isdir(directory):
continue
for filename in os.listdir(directory):
if not filename.endswith(".ini"):
continue
name, _ = os.path.splitext(filename)
read_only_sources.setdefault(name, (origin, os.path.join(directory, filename)))
for name, (_origin, src) in sorted(read_only_sources.items()):
# Ensure a copy in the settings default directory so existing code paths work unchanged
dst_default = default_profile_path(name)
if not os.path.exists(dst_default):
os.makedirs(os.path.dirname(dst_default), exist_ok=True)
shutil.copyfile(src, dst_default)
# Ensure a user copy exists to allow edits in the writable settings area
dst_user = user_profile_path(name)
if not os.path.exists(dst_user):
os.makedirs(os.path.dirname(dst_user), exist_ok=True)
shutil.copyfile(src, dst_user)
# Minimal metadata touch-up to align with existing expectations
s = open_user_settings(name)
if not s.value(SETTINGS_KEYS["created_at"], ""):
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
defaults |= set(read_only_sources.keys())
users |= set(read_only_sources.keys())
# Return union of all discovered names
return sorted(defaults | users)
def is_profile_readonly(name: str) -> bool:
"""Check if a profile is marked as read-only."""
settings = open_settings(name)
return settings.value(SETTINGS_KEYS["readonly"], False, type=bool)
def open_default_settings(name: str) -> QSettings:
return QSettings(default_profile_path(name), QSettings.IniFormat)
def set_profile_readonly(name: str, readonly: bool) -> None:
"""Set the read-only status of a profile."""
settings = open_settings(name)
settings.setValue(SETTINGS_KEYS["readonly"], readonly)
settings.sync()
def open_user_settings(name: str) -> QSettings:
return QSettings(user_profile_path(name), QSettings.IniFormat)
def open_settings(name: str) -> QSettings:
return QSettings(profile_path(name), QSettings.IniFormat)
def _app_settings() -> QSettings:
"""Return app-wide settings file for AdvancedDockArea metadata."""
return QSettings(os.path.join(_settings_profiles_root(), "_meta.ini"), QSettings.IniFormat)
def get_last_profile() -> str | None:
"""Return the last-used profile name if stored, else None."""
s = _app_settings()
name = s.value(SETTINGS_KEYS["last_profile"], "", type=str)
return name or None
def set_last_profile(name: str | None) -> None:
"""Persist the last-used profile name (or clear it if None)."""
s = _app_settings()
if name:
s.setValue(SETTINGS_KEYS["last_profile"], name)
else:
s.remove(SETTINGS_KEYS["last_profile"])
def now_iso_utc() -> str:
return QDateTime.currentDateTimeUtc().toString(Qt.ISODate)
def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None:
@@ -77,3 +298,197 @@ def read_manifest(settings: QSettings) -> list[dict]:
)
settings.endArray()
return items
def restore_user_from_default(name: str) -> None:
"""Overwrite the user profile with the default baseline (keep default intact)."""
src = default_profile_path(name)
dst = user_profile_path(name)
if not os.path.exists(src):
return
preserve_quick_select = is_quick_select(name)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copyfile(src, dst)
s = open_user_settings(name)
if not s.value(SETTINGS_KEYS["created_at"], ""):
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
if preserve_quick_select:
s.setValue(SETTINGS_KEYS["is_quick_select"], True)
def is_quick_select(name: str) -> bool:
"""Return True if profile is marked to appear in quick-select combo."""
s = (
open_user_settings(name)
if os.path.exists(user_profile_path(name))
else (open_default_settings(name) if os.path.exists(default_profile_path(name)) else None)
)
if s is None:
return False
return s.value(SETTINGS_KEYS["is_quick_select"], False, type=bool)
def set_quick_select(name: str, enabled: bool) -> None:
"""Set/unset the quick-select flag on the USER copy (creates it if missing)."""
s = open_user_settings(name)
s.setValue(SETTINGS_KEYS["is_quick_select"], bool(enabled))
def list_quick_profiles() -> list[str]:
"""List only profiles that have quick-select enabled (user wins over default)."""
names = list_profiles()
return [n for n in names if is_quick_select(n)]
def _file_modified_iso(path: str) -> str:
try:
mtime = os.path.getmtime(path)
return QDateTime.fromSecsSinceEpoch(int(mtime), Qt.UTC).toString(Qt.ISODate)
except Exception:
return now_iso_utc()
def _manifest_count(settings: QSettings) -> int:
n = settings.beginReadArray(SETTINGS_KEYS["manifest"])
settings.endArray()
return int(n or 0)
def _load_screenshot_from_settings(settings: QSettings) -> QPixmap | None:
data = settings.value(SETTINGS_KEYS["screenshot"], None)
if not data:
return None
buf = None
if isinstance(data, QByteArray):
buf = data
elif isinstance(data, (bytes, bytearray, memoryview)):
buf = bytes(data)
elif isinstance(data, str):
try:
buf = QByteArray(data.encode("latin-1"))
except Exception:
buf = None
if buf is None:
return None
pm = QPixmap()
ok = pm.loadFromData(buf)
return pm if ok and not pm.isNull() else None
class ProfileInfo(BaseModel):
name: str
author: str = "BEC Widgets"
notes: str = ""
created: str = Field(default_factory=now_iso_utc)
modified: str = Field(default_factory=now_iso_utc)
is_quick_select: bool = False
widget_count: int = 0
size_kb: int = 0
user_path: str = ""
default_path: str = ""
origin: ProfileOrigin = "unknown"
is_read_only: bool = False
def get_profile_info(name: str) -> ProfileInfo:
"""
Return merged metadata for a profile as a validated Pydantic model.
Prefers the USER copy; falls back to DEFAULT if the user copy is missing.
"""
u_path = user_profile_path(name)
d_path = default_profile_path(name)
origin = profile_origin(name)
prefer_user = os.path.exists(u_path)
read_only = origin in {"module", "plugin"}
s = (
open_user_settings(name)
if prefer_user
else (open_default_settings(name) if os.path.exists(d_path) else None)
)
if s is None:
if origin == "module":
author = "BEC Widgets"
elif origin == "plugin":
author = _plugin_display_name() or "Plugin"
elif origin == "settings":
author = "User"
else:
author = ""
return ProfileInfo(
name=name,
author=author,
notes="",
created=now_iso_utc(),
modified=now_iso_utc(),
is_quick_select=False,
widget_count=0,
size_kb=0,
user_path=u_path,
default_path=d_path,
origin=origin,
is_read_only=read_only,
)
created = s.value(SETTINGS_KEYS["created_at"], "", type=str) or now_iso_utc()
src_path = u_path if prefer_user else d_path
modified = _file_modified_iso(src_path)
count = _manifest_count(s)
try:
size_kb = int(os.path.getsize(src_path) / 1024)
except Exception:
size_kb = 0
settings_author = s.value("profile/author", "", type=str) or None
if origin == "module":
author = "BEC Widgets"
elif origin == "plugin":
author = _plugin_display_name() or "Plugin"
elif origin == "settings":
author = "User"
else:
author = settings_author or "user"
return ProfileInfo(
name=name,
author=author,
notes=s.value("profile/notes", "", type=str) or "",
created=created,
modified=modified,
is_quick_select=is_quick_select(name),
widget_count=count,
size_kb=size_kb,
user_path=u_path,
default_path=d_path,
origin=origin,
is_read_only=read_only,
)
def load_profile_screenshot(name: str) -> QPixmap | None:
"""Load the stored screenshot pixmap for a profile from settings (user preferred)."""
u_path = user_profile_path(name)
d_path = default_profile_path(name)
s = (
open_user_settings(name)
if os.path.exists(u_path)
else (open_default_settings(name) if os.path.exists(d_path) else None)
)
if s is None:
return None
return _load_screenshot_from_settings(s)
def load_user_profile_screenshot(name: str) -> QPixmap | None:
"""Load the screenshot from the user profile copy, if available."""
if not os.path.exists(user_profile_path(name)):
return None
return _load_screenshot_from_settings(open_user_settings(name))
def load_default_profile_screenshot(name: str) -> QPixmap | None:
"""Load the screenshot from the default profile copy, if available."""
if not os.path.exists(default_profile_path(name)):
return None
return _load_screenshot_from_settings(open_default_settings(name))

View File

@@ -0,0 +1,325 @@
from __future__ import annotations
from typing import Callable, Literal
from qtpy.QtCore import Qt
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from bec_widgets import SafeSlot
class SaveProfileDialog(QDialog):
"""Dialog for saving workspace profiles with quick select option."""
def __init__(
self,
parent: QWidget | None = None,
current_name: str = "",
current_profile_name: str = "",
*,
name_exists: Callable[[str], bool] | None = None,
profile_origin: (
Callable[[str], Literal["module", "plugin", "settings", "unknown"]] | None
) = None,
origin_label: Callable[[str], str | None] | None = None,
quick_select_checked: bool = False,
):
super().__init__(parent)
self.setWindowTitle("Save Workspace Profile")
self.setModal(True)
self.resize(400, 160)
self._name_exists = name_exists or (lambda _: False)
self._profile_origin = profile_origin or (lambda _: "unknown")
self._origin_label = origin_label or (lambda _: None)
self._current_profile_name = current_profile_name.strip()
self._previous_name_before_overwrite = current_name
self._block_name_signals = False
self._block_checkbox_signals = False
self.overwrite_existing = False
layout = QVBoxLayout(self)
# Name input
name_row = QHBoxLayout()
name_row.addWidget(QLabel("Profile Name:"))
self.name_edit = QLineEdit(current_name)
self.name_edit.setPlaceholderText("Enter profile name...")
name_row.addWidget(self.name_edit)
layout.addLayout(name_row)
# Overwrite checkbox
self.overwrite_checkbox = QCheckBox("Overwrite current profile")
self.overwrite_checkbox.setEnabled(bool(self._current_profile_name))
self.overwrite_checkbox.toggled.connect(self._on_overwrite_toggled)
layout.addWidget(self.overwrite_checkbox)
# Quick-select checkbox
self.quick_select_checkbox = QCheckBox("Include in quick selection.")
self.quick_select_checkbox.setChecked(quick_select_checked)
layout.addWidget(self.quick_select_checkbox)
# Buttons
btn_row = QHBoxLayout()
btn_row.addStretch(1)
self.save_btn = QPushButton("Save")
self.save_btn.setDefault(True)
cancel_btn = QPushButton("Cancel")
self.save_btn.clicked.connect(self.accept)
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(self.save_btn)
btn_row.addWidget(cancel_btn)
layout.addLayout(btn_row)
# Enable/disable save button based on name input
self.name_edit.textChanged.connect(self._on_name_changed)
self._update_save_button()
@SafeSlot(bool)
def _on_overwrite_toggled(self, checked: bool):
if self._block_checkbox_signals:
return
if not self._current_profile_name:
return
self._block_name_signals = True
if checked:
self._previous_name_before_overwrite = self.name_edit.text()
self.name_edit.setText(self._current_profile_name)
self.name_edit.selectAll()
else:
if self.name_edit.text().strip() == self._current_profile_name:
self.name_edit.setText(self._previous_name_before_overwrite or "")
self._block_name_signals = False
self._update_save_button()
@SafeSlot(str)
def _on_name_changed(self, _: str):
if self._block_name_signals:
return
text = self.name_edit.text().strip()
if self.overwrite_checkbox.isChecked() and text != self._current_profile_name:
self._block_checkbox_signals = True
self.overwrite_checkbox.setChecked(False)
self._block_checkbox_signals = False
self._update_save_button()
def _update_save_button(self):
"""Enable save button only when name is not empty."""
self.save_btn.setEnabled(bool(self.name_edit.text().strip()))
def get_profile_name(self) -> str:
"""Return the entered profile name."""
return self.name_edit.text().strip()
def is_quick_select(self) -> bool:
"""Return whether the profile should appear in quick select."""
return self.quick_select_checkbox.isChecked()
def _generate_unique_name(self, base: str) -> str:
candidate_base = base.strip() or "profile"
suffix = "_custom"
candidate = f"{candidate_base}{suffix}"
counter = 1
while self._name_exists(candidate) or self._profile_origin(candidate) != "unknown":
candidate = f"{candidate_base}{suffix}_{counter}"
counter += 1
return candidate
def accept(self):
name = self.get_profile_name()
if not name:
return
self.overwrite_existing = False
origin = self._profile_origin(name)
if origin in {"module", "plugin"}:
source_label = self._origin_label(name)
if origin == "module":
provider = source_label or "BEC Widgets"
else:
provider = (
f"the {source_label} plugin repository"
if source_label
else "the plugin repository"
)
QMessageBox.information(
self,
"Read-only profile",
(
f"'{name}' is a default profile provided by {provider} and cannot be overwritten.\n"
"Please choose a different name."
),
)
suggestion = self._generate_unique_name(name)
self._block_name_signals = True
self.name_edit.setText(suggestion)
self.name_edit.selectAll()
self._block_name_signals = False
self._block_checkbox_signals = True
self.overwrite_checkbox.setChecked(False)
self._block_checkbox_signals = False
return
if origin == "settings":
reply = QMessageBox.question(
self,
"Overwrite profile",
(
f"A profile named '{name}' already exists.\n\n"
"Overwriting will update both the saved profile and its restore default.\n"
"Do you want to continue?"
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
suggestion = self._generate_unique_name(name)
self._block_name_signals = True
self.name_edit.setText(suggestion)
self.name_edit.selectAll()
self._block_name_signals = False
self._block_checkbox_signals = True
self.overwrite_checkbox.setChecked(False)
self._block_checkbox_signals = False
return
self.overwrite_existing = True
super().accept()
class PreviewPanel(QGroupBox):
"""Resizable preview pane that scales its pixmap with aspect ratio preserved."""
def __init__(self, title: str, pixmap: QPixmap | None, parent: QWidget | None = None):
super().__init__(title, parent)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._original: QPixmap | None = pixmap if (pixmap and not pixmap.isNull()) else None
layout = QVBoxLayout(self)
# layout.setContentsMargins(0,0,0,0) # leave room for group title and frame
self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignCenter)
self.image_label.setMinimumSize(360, 240)
self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
layout.addWidget(self.image_label, 1)
if self._original:
self._update_scaled_pixmap()
else:
self.image_label.setText("No preview available")
self.image_label.setStyleSheet(
self.image_label.styleSheet() + "color: rgba(255,255,255,0.6); font-style: italic;"
)
def setPixmap(self, pixmap: QPixmap | None):
self._original = pixmap if (pixmap and not pixmap.isNull()) else None
if self._original:
self.image_label.setText("")
self._update_scaled_pixmap()
else:
self.image_label.setPixmap(QPixmap())
self.image_label.setText("No preview available")
def resizeEvent(self, event):
super().resizeEvent(event)
if self._original:
self._update_scaled_pixmap()
def _update_scaled_pixmap(self):
if not self._original:
return
size = self.image_label.size()
if size.width() <= 0 or size.height() <= 0:
return
scaled = self._original.scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
self.image_label.setPixmap(scaled)
class RestoreProfileDialog(QDialog):
"""
Confirmation dialog that previews the current profile screenshot against the default baseline.
"""
def __init__(
self, parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None
):
super().__init__(parent)
self.setWindowTitle("Restore Profile to Default")
self.setModal(True)
self.resize(880, 480)
layout = QVBoxLayout(self)
info_label = QLabel(
"Restoring will discard your custom layout and replace it with the default profile."
)
info_label.setWordWrap(True)
layout.addWidget(info_label)
preview_row = QHBoxLayout()
layout.addLayout(preview_row)
current_preview = PreviewPanel("Current", current_pixmap, self)
default_preview = PreviewPanel("Default", default_pixmap, self)
# Equal expansion left/right
preview_row.addWidget(current_preview, 1)
arrow_label = QLabel("\u2192")
arrow_label.setAlignment(Qt.AlignCenter)
arrow_label.setStyleSheet("font-size: 32px; padding: 0 16px;")
arrow_label.setMinimumWidth(40)
arrow_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
preview_row.addWidget(arrow_label)
preview_row.addWidget(default_preview, 1)
# Enforce equal stretch for both previews
preview_row.setStretch(0, 1)
preview_row.setStretch(1, 0)
preview_row.setStretch(2, 1)
warn_label = QLabel(
"This action cannot be undone. Do you want to restore the default layout now?"
)
warn_label.setWordWrap(True)
layout.addWidget(warn_label)
btn_row = QHBoxLayout()
btn_row.addStretch(1)
restore_btn = QPushButton("Restore")
restore_btn.setDefault(True)
cancel_btn = QPushButton("Cancel")
restore_btn.clicked.connect(self.accept)
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(restore_btn)
btn_row.addWidget(cancel_btn)
layout.addLayout(btn_row)
# Make the previews take most of the vertical space on resize
layout.setStretch(0, 0) # info label
layout.setStretch(1, 1) # preview row
layout.setStretch(2, 0) # warning label
layout.setStretch(3, 0) # buttons
@staticmethod
def confirm(
parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None
) -> bool:
dialog = RestoreProfileDialog(parent, current_pixmap, default_pixmap)
return dialog.exec() == QDialog.Accepted

View File

@@ -0,0 +1,404 @@
from __future__ import annotations
from functools import partial
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import (
QAbstractItemView,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPushButton,
QSizePolicy,
QSplitter,
QStyledItemDelegate,
QTableWidget,
QTableWidgetItem,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from bec_widgets import BECWidget, SafeSlot
from bec_widgets.utils.colors import apply_theme, get_accent_colors
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
delete_profile_files,
get_profile_info,
is_quick_select,
list_profiles,
load_profile_screenshot,
set_quick_select,
)
class WorkSpaceManager(BECWidget, QWidget):
RPC = False
PLUGIN = False
COL_ACTIONS = 0
COL_NAME = 1
COL_AUTHOR = 2
HEADERS = ["Actions", "Profile", "Author"]
def __init__(
self, parent=None, target_widget=None, default_profile: str | None = None, **kwargs
):
super().__init__(parent=parent, **kwargs)
self.target_widget = target_widget
self.accent_colors = get_accent_colors()
self._init_ui()
if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"):
self.target_widget.profile_changed.connect(self.on_profile_changed)
if default_profile is not None:
self._select_by_name(default_profile)
self._show_profile_details(default_profile)
def _init_ui(self):
self.root_layout = QHBoxLayout(self)
self.splitter = QSplitter(Qt.Horizontal, self)
self.root_layout.addWidget(self.splitter)
# Init components
self._init_profile_table()
self._init_profile_details_tree()
self._init_screenshot_preview()
# Build two-column layout
left_col = QVBoxLayout()
left_col.addWidget(self.profile_table, 1)
left_col.addWidget(self.profile_details_tree, 0)
self.save_profile_button = QPushButton("Save current layout as new profile", self)
self.save_profile_button.clicked.connect(self.save_current_as_profile)
left_col.addWidget(self.save_profile_button)
self.save_profile_button.setEnabled(self.target_widget is not None)
# Wrap left widgets into a panel that participates in splitter sizing
left_panel = QWidget(self)
left_panel.setLayout(left_col)
left_panel.setMinimumWidth(220)
# Make the screenshot preview expand to fill remaining space
self.screenshot_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.right_box = QGroupBox("Profile Screenshot Preview", self)
right_col = QVBoxLayout(self.right_box)
right_col.addWidget(self.screenshot_label, 1)
self.splitter.addWidget(left_panel)
self.splitter.addWidget(self.right_box)
self.splitter.setStretchFactor(0, 0)
self.splitter.setStretchFactor(1, 1)
self.splitter.setSizes([350, 650])
def _init_profile_table(self):
self.profile_table = QTableWidget(self)
self.profile_table.setColumnCount(len(self.HEADERS))
self.profile_table.setHorizontalHeaderLabels(self.HEADERS)
self.profile_table.setAlternatingRowColors(True)
self.profile_table.verticalHeader().setVisible(False)
# Enforce row selection, single-select, and disable edits
self.profile_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.profile_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.profile_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
# Ensure the table expands to use vertical space in the left panel
self.profile_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
header = self.profile_table.horizontalHeader()
header.setStretchLastSection(False)
header.setDefaultAlignment(Qt.AlignCenter)
class _CenterDelegate(QStyledItemDelegate):
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
option.displayAlignment = Qt.AlignCenter
self.profile_table.setItemDelegate(_CenterDelegate(self.profile_table))
header.setSectionResizeMode(self.COL_ACTIONS, QHeaderView.ResizeToContents)
header.setSectionResizeMode(self.COL_NAME, QHeaderView.Stretch)
header.setSectionResizeMode(self.COL_AUTHOR, QHeaderView.ResizeToContents)
self.render_table()
self.profile_table.itemSelectionChanged.connect(self._on_table_selection_changed)
self.profile_table.cellClicked.connect(self._on_cell_clicked)
def _init_profile_details_tree(self):
self.profile_details_tree = QTreeWidget(self)
self.profile_details_tree.setHeaderLabels(["Field", "Value"])
# Keep details compact so the table can expand
self.profile_details_tree.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
def _init_screenshot_preview(self):
self.screenshot_label = QLabel(self)
self.screenshot_label.setMinimumHeight(160)
self.screenshot_label.setAlignment(Qt.AlignCenter)
def render_table(self):
self.profile_table.setRowCount(0)
for profile in list_profiles():
self._add_profile_row(profile)
def _add_profile_row(self, name: str):
row = self.profile_table.rowCount()
self.profile_table.insertRow(row)
actions_items = QWidget(self)
actions_items.profile_name = name
actions_items_layout = QHBoxLayout(actions_items)
actions_items_layout.setContentsMargins(0, 0, 0, 0)
info = get_profile_info(name)
# Flags
is_active = (
self.target_widget is not None
and getattr(self.target_widget, "_current_profile_name", None) == name
)
quick = info.is_quick_select
is_read_only = info.is_read_only
# Play (green if active)
self._make_action_button(
actions_items,
"play_circle",
"Switch to this profile",
self.switch_profile,
filled=is_active,
color=(self.accent_colors.success if is_active else None),
)
# Quick-select (yellow if enabled)
self._make_action_button(
actions_items,
"star",
"Include in quick selection",
self.toggle_quick_select,
filled=quick,
color=(self.accent_colors.warning if quick else None),
)
# Delete (red, disabled when read-only)
delete_button = self._make_action_button(
actions_items,
"delete",
"Delete this profile",
self.delete_profile,
color=self.accent_colors.emergency,
)
if is_read_only:
delete_button.setEnabled(False)
delete_button.setToolTip("Bundled profiles are read-only and cannot be deleted.")
actions_items_layout.addStretch()
self.profile_table.setCellWidget(row, self.COL_ACTIONS, actions_items)
self.profile_table.setItem(row, self.COL_NAME, QTableWidgetItem(name))
self.profile_table.setItem(row, self.COL_AUTHOR, QTableWidgetItem(info.author))
def _make_action_button(
self,
parent: QWidget,
icon_name: str,
tooltip: str,
slot: callable,
*,
filled: bool = False,
color: str | None = None,
):
button = QToolButton(parent=parent)
button.setIcon(material_icon(icon_name, filled=filled, color=color))
button.setToolTip(tooltip)
button.clicked.connect(partial(slot, parent.profile_name))
parent.layout().addWidget(button)
return button
def _select_by_name(self, name: str) -> None:
for row in range(self.profile_table.rowCount()):
item = self.profile_table.item(row, self.COL_NAME)
if item and item.text() == name:
self.profile_table.selectRow(row)
break
def _current_selected_profile(self) -> str | None:
rows = self.profile_table.selectionModel().selectedRows()
if not rows:
return None
row = rows[0].row()
item = self.profile_table.item(row, self.COL_NAME)
return item.text() if item else None
def _show_profile_details(self, name: str) -> None:
info = get_profile_info(name)
self.profile_details_tree.clear()
entries = [
("Name", info.name),
("Author", info.author or ""),
("Created", info.created or ""),
("Modified", info.modified or ""),
("Quick select", "Yes" if info.is_quick_select else "No"),
("Widgets", str(info.widget_count)),
("Size (KB)", str(info.size_kb)),
("User path", info.user_path or ""),
("Default path", info.default_path or ""),
]
for k, v in entries:
self.profile_details_tree.addTopLevelItem(QTreeWidgetItem([k, v]))
self.profile_details_tree.expandAll()
# Render screenshot preview from profile INI
pm = load_profile_screenshot(name)
if pm is not None and not pm.isNull():
scaled = pm.scaled(
self.screenshot_label.width() or 800,
self.screenshot_label.height() or 450,
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
self.screenshot_label.setPixmap(scaled)
else:
self.screenshot_label.setPixmap(QPixmap())
@SafeSlot()
def _on_table_selection_changed(self):
name = self._current_selected_profile()
if name:
self._show_profile_details(name)
@SafeSlot(int, int)
def _on_cell_clicked(self, row: int, column: int):
item = self.profile_table.item(row, self.COL_NAME)
if item:
self._show_profile_details(item.text())
##################################################
# Public Slots
##################################################
@SafeSlot(str)
def on_profile_changed(self, name: str):
"""Keep the manager in sync without forcing selection to the active profile."""
selected = self._current_selected_profile()
self.render_table()
if selected:
self._select_by_name(selected)
self._show_profile_details(selected)
@SafeSlot(str)
def switch_profile(self, profile_name: str):
self.target_widget.load_profile(profile_name)
try:
self.target_widget.toolbar.components.get_action(
"workspace_combo"
).widget.setCurrentText(profile_name)
except Exception as e:
print(f"Warning: Could not update workspace combo box. {e}")
pass
self.render_table()
self._select_by_name(profile_name)
self._show_profile_details(profile_name)
@SafeSlot(str)
def toggle_quick_select(self, profile_name: str):
enabled = is_quick_select(profile_name)
set_quick_select(profile_name, not enabled)
self.render_table()
if self.target_widget is not None:
self.target_widget._refresh_workspace_list()
name = self._current_selected_profile()
if name:
self._show_profile_details(name)
@SafeSlot()
def save_current_as_profile(self):
if self.target_widget is None:
QMessageBox.information(
self,
"Save Profile",
"No workspace is associated with this manager. Attach a workspace to save profiles.",
)
return
self.target_widget.save_profile()
# AdvancedDockArea will emit profile_changed which will trigger table refresh,
# but ensure the UI stays in sync even if the signal is delayed.
self.render_table()
current = getattr(self.target_widget, "_current_profile_name", None)
if current:
self._select_by_name(current)
self._show_profile_details(current)
@SafeSlot(str)
def delete_profile(self, profile_name: str):
info = get_profile_info(profile_name)
if info.is_read_only:
QMessageBox.information(
self, "Delete Profile", "This profile is read-only and cannot be deleted."
)
return
reply = QMessageBox.question(
self,
"Delete Profile",
(
f"Delete the profile '{profile_name}'?\n\n"
"This will remove both the user and default copies."
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
try:
removed = delete_profile_files(profile_name)
except OSError as exc:
QMessageBox.warning(
self, "Delete Profile", f"Failed to delete profile '{profile_name}': {exc}"
)
return
if not removed:
QMessageBox.information(
self, "Delete Profile", "No writable profile files were found to delete."
)
return
if self.target_widget is not None:
if getattr(self.target_widget, "_current_profile_name", None) == profile_name:
self.target_widget._current_profile_name = None
if hasattr(self.target_widget, "_refresh_workspace_list"):
self.target_widget._refresh_workspace_list()
self.render_table()
remaining_profiles = list_profiles()
if remaining_profiles:
next_profile = remaining_profiles[0]
self._select_by_name(next_profile)
self._show_profile_details(next_profile)
else:
self.profile_details_tree.clear()
self.screenshot_label.setPixmap(QPixmap())
def resizeEvent(self, event):
super().resizeEvent(event)
name = self._current_selected_profile()
if not name:
return
pm = load_profile_screenshot(name)
if pm is None or pm.isNull():
return
scaled = pm.scaled(
self.screenshot_label.width() or 800,
self.screenshot_label.height() or 450,
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
self.screenshot_label.setPixmap(scaled)

File diff suppressed because one or more lines are too long

View File

@@ -1,17 +1,14 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QComboBox, QSizePolicy, QWidget
from qtpy.QtGui import QFont
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
is_profile_readonly,
list_profiles,
)
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import list_quick_profiles
class ProfileComboBox(QComboBox):
@@ -21,22 +18,47 @@ class ProfileComboBox(QComboBox):
super().__init__(parent)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
def refresh_profiles(self):
"""Refresh the profile list with appropriate icons."""
def refresh_profiles(self, active_profile: str | None = None):
"""
Refresh the profile list and ensure the active profile is visible.
current_text = self.currentText()
Args:
active_profile(str | None): The currently active profile name.
"""
current_text = active_profile or self.currentText()
self.blockSignals(True)
self.clear()
lock_icon = material_icon("edit_off", size=(16, 16), convert_to_pixmap=False)
quick_profiles = list_quick_profiles()
quick_set = set(quick_profiles)
for profile in list_profiles():
if is_profile_readonly(profile):
self.addItem(lock_icon, f"{profile}")
# Set tooltip for read-only profiles
self.setItemData(self.count() - 1, "Read-only profile", Qt.ToolTipRole)
else:
self.addItem(profile)
items = list(quick_profiles)
if active_profile and active_profile not in quick_set:
items.insert(0, active_profile)
for profile in items:
self.addItem(profile)
idx = self.count() - 1
# Reset any custom styling
self.setItemData(idx, None, Qt.FontRole)
self.setItemData(idx, None, Qt.ToolTipRole)
self.setItemData(idx, None, Qt.ForegroundRole)
if active_profile and profile == active_profile:
tooltip = "Active workspace profile"
if profile not in quick_set:
font = QFont(self.font())
font.setItalic(True)
font.setBold(True)
self.setItemData(idx, font, Qt.FontRole)
self.setItemData(idx, self.palette().highlight().color(), Qt.ForegroundRole)
tooltip = "Active profile (not in quick select)"
self.setItemData(idx, tooltip, Qt.ToolTipRole)
self.setCurrentIndex(idx)
elif profile not in quick_set:
self.setItemData(idx, "Not in quick select", Qt.ToolTipRole)
# Restore selection if possible
index = self.findText(current_text)
@@ -44,6 +66,14 @@ class ProfileComboBox(QComboBox):
self.setCurrentIndex(index)
self.blockSignals(False)
if active_profile and self.currentText() != active_profile:
idx = self.findText(active_profile)
if idx >= 0:
self.setCurrentIndex(idx)
if active_profile and active_profile not in quick_set:
self.setToolTip("Active profile is not in quick select")
else:
self.setToolTip("")
def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle:
@@ -56,17 +86,6 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle:
Returns:
ToolbarBundle: The workspace toolbar bundle.
"""
# Lock icon action
components.add_safe(
"lock",
MaterialIconAction(
icon_name="lock_open_right",
tooltip="Lock Workspace",
checkable=True,
parent=components.toolbar,
),
)
# Workspace combo
combo = ProfileComboBox(parent=components.toolbar)
components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False))
@@ -83,31 +102,31 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle:
)
# Delete workspace icon
components.add_safe(
"refresh_workspace",
"reset_default_workspace",
MaterialIconAction(
icon_name="refresh",
icon_name="undo",
tooltip="Refresh Current Workspace",
checkable=False,
parent=components.toolbar,
),
)
# Delete workspace icon
# Workspace Manager icon
components.add_safe(
"delete_workspace",
"manage_workspaces",
MaterialIconAction(
icon_name="delete",
tooltip="Delete Current Workspace",
checkable=False,
icon_name="manage_accounts",
tooltip="Manage",
checkable=True,
parent=components.toolbar,
label_text="Manage",
),
)
bundle = ToolbarBundle("workspace", components)
bundle.add_action("lock")
bundle.add_action("workspace_combo")
bundle.add_action("save_workspace")
bundle.add_action("refresh_workspace")
bundle.add_action("delete_workspace")
bundle.add_action("reset_default_workspace")
bundle.add_action("manage_workspaces")
return bundle
@@ -128,56 +147,40 @@ class WorkspaceConnection(BundleConnection):
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action("lock").action.toggled.connect(self._lock_workspace)
self.components.get_action("save_workspace").action.triggered.connect(
self.target_widget.save_profile
)
self.components.get_action("workspace_combo").widget.currentTextChanged.connect(
self.target_widget.load_profile
)
self.components.get_action("refresh_workspace").action.triggered.connect(
self._refresh_workspace
self.components.get_action("reset_default_workspace").action.triggered.connect(
self._reset_workspace_to_default
)
self.components.get_action("delete_workspace").action.triggered.connect(
self.target_widget.delete_profile
self.components.get_action("manage_workspaces").action.triggered.connect(
self.target_widget.show_workspace_manager
)
def disconnect(self):
if not self._connected:
return
# Disconnect the action from the target widget's method
self.components.get_action("lock").action.toggled.disconnect(self._lock_workspace)
self.components.get_action("save_workspace").action.triggered.disconnect(
self.target_widget.save_profile
)
self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect(
self.target_widget.load_profile
)
self.components.get_action("refresh_workspace").action.triggered.disconnect(
self._refresh_workspace
self.components.get_action("reset_default_workspace").action.triggered.disconnect(
self._reset_workspace_to_default
)
self.components.get_action("delete_workspace").action.triggered.disconnect(
self.target_widget.delete_profile
self.components.get_action("manage_workspaces").action.triggered.disconnect(
self.target_widget.show_workspace_manager
)
self._connected = False
@SafeSlot(bool)
def _lock_workspace(self, value: bool):
"""
Switches the workspace lock state and change the icon accordingly.
"""
setattr(self.target_widget, "lock_workspace", value)
self.components.get_action("lock").action.setChecked(value)
icon = material_icon(
"lock" if value else "lock_open_right", size=(20, 20), convert_to_pixmap=False
)
self.components.get_action("lock").action.setIcon(icon)
@SafeSlot()
def _refresh_workspace(self):
def _reset_workspace_to_default(self):
"""
Refreshes the current workspace.
"""
combo = self.components.get_action("workspace_combo").widget
current_workspace = combo.currentText()
self.target_widget.load_profile(current_workspace)
self.target_widget.restore_user_profile_from_default()

File diff suppressed because it is too large Load Diff