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:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user