diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 8bdee4a7..489d2451 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -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/.ini with tag=default and created_at + - writes a user copy to states/user/.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) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index 47fe1ddd..a49183ff 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -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: /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)) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/__init__.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py new file mode 100644 index 00000000..e9d60f3f --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py @@ -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 diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py new file mode 100644 index 00000000..93622747 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py @@ -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) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini b/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini deleted file mode 100644 index 6188162c..00000000 --- a/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini +++ /dev/null @@ -1,234 +0,0 @@ -[BECMainWindowNoRPC.AdvancedDockArea] -acceptDrops=false -accessibleDescription= -accessibleIdentifier= -accessibleName= -autoFillBackground=false -baseSize=@Size(0 0) -contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) -cursor=@Variant(\0\0\0J\0\0) -enabled=true -focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -geometry=@Rect(0 29 2075 974) -inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) -layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) -locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) -lock_workspace=false -maximumSize=@Size(16777215 16777215) -minimumSize=@Size(0 0) -mode=developer -mouseTracking=false -palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) -sizeIncrement=@Size(0 0) -sizePolicy=@Variant(\0\0\0K\0\0\0U) -statusTip= -styleSheet= -tabletTracking=false -toolTip= -toolTipDuration=-1 -updatesEnabled=true -visible=true -whatsThis= -windowFilePath= -windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" -windowIconText= -windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) -windowModified=false -windowOpacity=1 -windowTitle=Advanced Dock Area - -[BECMainWindowNoRPC.AdvancedDockArea.CDockManager.ads%3A%3ACDockSplitter.ads%3A%3ACDockAreaWidget.BECQueue.dockWidgetScrollArea.qt_scrollarea_viewport.BECQueue] -acceptDrops=false -accessibleDescription= -accessibleIdentifier= -accessibleName= -autoFillBackground=false -baseSize=@Size(0 0) -compact_view=false -contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) -cursor=@Variant(\0\0\0J\0\0) -enabled=true -expand_popup=true -focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -geometry=@Rect(0 0 1252 897) -hide_toolbar=false -inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) -label=BEC Queue -layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) -locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) -maximumSize=@Size(16777215 16777215) -minimumSize=@Size(0 0) -mouseTracking=false -palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) -sizeIncrement=@Size(0 0) -sizePolicy=@Variant(\0\0\0K\0\0\0U) -statusTip= -styleSheet= -tabletTracking=false -toolTip= -toolTipDuration=-1 -tooltip=BEC Queue status -updatesEnabled=true -visible=true -whatsThis= -windowFilePath= -windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" -windowIconText= -windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) -windowModified=false -windowOpacity=1 -windowTitle= - -[BECMainWindowNoRPC.AdvancedDockArea.CDockManager.ads%3A%3ACDockSplitter.ads%3A%3ACDockAreaWidget.Waveform.dockWidgetScrollArea.qt_scrollarea_viewport.Waveform] -acceptDrops=false -accessibleDescription= -accessibleIdentifier= -accessibleName= -autoFillBackground=false -auto_range_x=true -auto_range_y=true -baseSize=@Size(0 0) -color_palette=plasma -contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) -cursor=@Variant(\0\0\0J\0\0) -curve_json=[] -enable_fps_monitor=false -enable_popups=true -enable_side_panel=false -enable_toolbar=true -enabled=true -focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -geometry=@Rect(0 0 798 897) -inner_axes=true -inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) -layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) -legend_label_size=9 -locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) -lock_aspect_ratio=false -max_dataset_size_mb=10 -maximumSize=@Size(16777215 16777215) -minimal_crosshair_precision=3 -minimumSize=@Size(0 0) -mouseTracking=false -outer_axes=false -palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) -sizeIncrement=@Size(0 0) -sizePolicy=@Variant(\0\0\0K\0\0\0U) -skip_large_dataset_check=false -skip_large_dataset_warning=false -statusTip= -styleSheet= -tabletTracking=false -title= -toolTip= -toolTipDuration=-1 -updatesEnabled=true -visible=true -whatsThis= -windowFilePath= -windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" -windowIconText= -windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) -windowModified=false -windowOpacity=1 -windowTitle= -x_entry= -x_grid=false -x_label= -x_limits=@Variant(\0\0\0\x1a\0\0\0\0\0\0\0\0?\xf0\0\0\0\0\0\0) -x_log=false -x_mode=auto -y_grid=false -y_label= -y_limits=@Variant(\0\0\0\x1a\0\0\0\0\0\0\0\0?\xf0\0\0\0\0\0\0) -y_log=false - -[BECMainWindowNoRPC.AdvancedDockArea.ModularToolBar.QWidget.DarkModeButton] -acceptDrops=false -accessibleDescription= -accessibleIdentifier= -accessibleName= -autoFillBackground=false -baseSize=@Size(0 0) -contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) -cursor=@Variant(\0\0\0J\0\0) -dark_mode_enabled=false -enabled=true -focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -geometry=@Rect(0 0 40 40) -inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) -layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) -locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) -maximumSize=@Size(40 40) -minimumSize=@Size(40 40) -mouseTracking=false -palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) -sizeIncrement=@Size(0 0) -sizePolicy=@Variant(\0\0\0K\0\0\0U) -statusTip= -styleSheet= -tabletTracking=false -toolTip= -toolTipDuration=-1 -updatesEnabled=true -visible=true -whatsThis= -windowFilePath= -windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" -windowIconText= -windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) -windowModified=false -windowOpacity=1 -windowTitle= - -[BECMainWindowNoRPC.AdvancedDockArea.dockSettingsAction] -autoRepeat=true -checkable=false -checked=false -enabled=true -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -icon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\0\tpHYs\0\0\v\x13\0\0\v\x13\x1\0\x9a\x9c\x18\0\0\x4\xc9IDATX\x85\xed\x99]lSu\x18\xc6\x7f\xef\xe9\x6\x85\xb0\xa0\xc1\x8b\xe9\x2\x46\xe3`\xa0!\xa0\x88\x6\x35*\x9a\xb9\x96\xb6\v\xed*\x82\x80\x46\x12\xe3\x95\x1a\x14I\xc4\x8b\xde(~$&&\x10/d\x17\x80\xdf]7i\xb7u \xc1%\x9a\x18\xd4\xa0\xf1\x3\x18j\"D\x3\xc6\xc4\x44\x18\xee\xa3=\xff\xd7\v$t]{\xce\x61\xad\xf3\x66\xcf\xddy\xdf\xe7<\xefs\xcey\xfb?\xef\xf9\x17\xa6\x30\x85\x31\x90j\x88\xb4\x84\xa2\xab\x5iu\xe2(\xba\xaf\xaf\xbb\xb3\xab\xd2Z5\x95\n\0 \xb2\x1a\xd8\xe0\x42\xb2\x80\x8a\r[\x95\n\0\b,\xf4\xc0i\xaa\x46\xadj\x18\x16\xbc\x99i\xa2\n-X\xb1\xe1P(~\r0\xcb\x3\xb5.\x10\x88\x36TZ\xcf\xd1p(\x14o\b\x6\xe3\xf5N\x9c\x9c\xcf\xb8\xb6\xc3\x45H\xad:r\x83\xc1x}(\x14w\xbc(G\xc3y1;\xd5gN\x6\xc3\xb1]\xcd\xe1\xd8\xb8\xc7\x1e\x88\xc4\x96Z\x86-\xde\xec\x82\xaal[\xd5\xda\xb6\xa2\x38\xde\x12\x89/h\tG\xdfR\x9f\x39i\x8b\xd9\xe1\xa4Q\xb6\xa7VE\xa2\xf7\x1a\x95\x43\x45\xe1\x8c\x31\xf2Z~\x9a}tZNv \xf2\x90W\xb3\x63\xabJ\x97\xed\xb3\x9f\xf0\xd9\xd2\xa8*[\x4\"\x85^,\xd1\x95=\xe9\xceO<\x1b\x8e\xc7\xe3\xbe\xc1\x11\xf3\x15\xca\x92\x32%\x87\x1\xff\x84\xccz\xd1\x10\xbe\x99\x35\xddZ\x96L&\xed\xe2T\xc9\x96\x18\x1c\x31\x8f\x38\x98\xa5l\xa1\xcb\x43y\re\xc9\xf9!\xddX*5\xee\xeG\"\x91\xba\x9c\xd6\x9e\0\x1c\x7fl\x93\x80\x33\x43~\xab\xb1?\x99\x1c,\f\x8e\xbb\xc3\x39\xad\xdd\xca\xffo\x16\xa0~\xe6\x88n-\xe\x8e\xb9\xc3\xe1p|^\x1e\x33@u\x1ey50\\\x83\xb5 \x93I\x9e\xba\x18\x18s\x87m\xb1\x1f\xa6\x12\xb3\xa2{\xc0\xba\xa7Vr\xf5\x62[W\x83\xb9\xf\xf8`\xc2z\xe0\xcf\x61\xd6\x15\x6\xc6\f?\xcbo^\xfc\xca\xe1#\xdf\x8e\xa2\xb2\x1d\xa8\xbd\f\xe1?Ty\xb4/\xd3\xd9[\x14?\x3\x1cj\x89\xb4}(\xaa\xed\xc0\x15\x97\xa1\x39\x82\xf0l_:\xb5\xb3\x30XrYk\tGo\x15\xe4}\xe0z/\xcajh\xe9\xebI\xedw\xe2\x4\xc3\xb1\x98\x42\x87G\xb3\xdfY\xc6\xac\xeb\xe9\xe9\xfa\xbe\x38QrY\xeb\xcbt~I\xde\xbf\x14x\xd7MY\xa0\xdd\xcd,@o&\x95\xc2K{(o\f\x9d\xab[^\xca,8\xcc\xc3\xd9\xec;g\x81\xf5\x81p\xb4\x1\xe4\xeer<\xa3V\xbb\xab\x89\x7f!b\xdaU\xad\x35\x65\xf3\xca\xc1\xde\xee\xd4\xd3N\x1an\xd3\x9a\"\xe2\x38\x89\xf9kr\xc7\\4.\x89\xe5\xc4\x91\xab\x16W\xbaiTe\x80\x9fL\xb8\x19\x16T\a\x9d\b\xc3\xf9\xda\xaa\x8d\x97\xa8\xfc\xe5\xa6Q\xb6\x87\xef\x8f\xc7gO\x1b\x36o*\x94\xed_\0K\xcc&\xe0s\xb7\x42\0\xaa\xd6&\x17\xc6\xca`8\xb6+?2\xf3\xa9\x3\a\xf6\x9e/\xc5(\xb9\xac\x5[c\xb7\x61xO\xe1:OF\xaa\xbd\xac\t'\x8c\xea\xda\xfd\x99\xce#\xc5)_\xe1\x41\"\x91\xb0\x66\xd4]\xf5\x1c\xca\xdb\xc0\x1cO\xe2\x80\b\xf\xdc\x30\x7f\xd1\xf?\x9d\x38\xf6\x63\xa9|K\xa4-\n\xb4\xe3\xfd-:G\x90\xc7\x1a\x9b\x16\xfe\xbd~\xed\x9a\xc3\xfd\xfd\xfdz\xe9Z\n\x10\b\xb7=\xf\xfa\xa2W\xa3\xc5P\xd8+X\xed\x62\x33`\xfbm\x91Qk\x11\xa2\x8f\v<8QM\x90m\xd9L\xc7K\x17\x8f\xc6\xf4\xb0m\xe5\xf6\xfaL\xcd\v\xc0\x8c\tI\xc3\x6\x30\x1b\xd4\aVN@\xd4\xfd$g\f\x91\x37{\n\x3\x63Z\xe2\xe7\x81\x81\xb3\x8d\v\x16NwzQL2\xb6g{:\xd3\x85\x81q\xcb\xda\x90\xdf\xf7*pz\xd2,\x95\xc7\xe9!\xbf\xf5Zq\xd0W\x1c\xf8\xe5\xe8\xd1\xd1\xf9\xf3o\xfc\x13\xc1q\xaf\xec\xbf\x86\xa2O\x1e\xecJ}Q\x1c/\xf9\xe2X\xbe\xec\xa6\xdd\xc0\xd7\xez\xc3U\xf0\xe4\xa4q\xe4\xf6[\x16\xef)\x95(i8\x91H\x18U\xd9\\\x14V\xe0#\xcb\x92;\xec\x1as-\x90\x9a\x98OP\xd8G\xde\x9a\x87\xe8\x9d\n\xfbJ\xd8\xda\x9cH$L\xa9s\x1d\xf7\xba\x2\xe1X\x17\x10\0v\xabX\xaf\xf7\xa5\x93\x3\x85\xf9U\xadm+T\xf5\x65U\xee\xf2\x62T\x84Om[\xb6\xed\xef\xe9\xf8\xec\x82\xef\vh\xe\xc7\x9a,\xf4\x19\x41\x36\"\xd2\x93MwD\xcbj8\x15\b\x85\xe2\r\x96\x35\x92O\xa7\xd3\xbf\x97\xbf\xa8\xb6\x66P\xd7y\xf8\x82\x61\xd3\xdc\x9b\xee\xfa\xb8\\>\x18\x8c\xd7[\x16\xbe\xee\xee\xe4o\x13\x32\xec\x5\xcd\xad\xads}\xa6\xe6\x94;\x13\xc8\xeb\xdcl\xb6\xf3\xd7J\xeaUc\a^\x2\xe1\xd8Y\xdcw0\xcf\x65\x33\xa9\xd9\x14\xb4\xc2\x44P\x8dyX\x81\xe3\x1ex\xc7\xa9\xd0,Ti\x80Wp\xff\xea\x10\xf5\xfc\x65\xe2\x84\xaa\xfc\xc7\x61!)\xd0Q'\x8e\x81\xb4S~\nS\x98 \xfe\x1\x1\xb5\x93\xa4\x97\x89\xb7\xcb\0\0\0\0IEND\xae\x42`\x82)" -iconText=Dock settings -iconVisibleInMenu=false -menuRole=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0`\x80\x4\x95U\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\rPySide6.QtGui\x94\x8c\x10QAction.MenuRole\x94\x93\x94\x8c\x11TextHeuristicRole\x94\x86\x94R\x94.) -priority=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\rPySide6.QtGui\x94\x8c\x10QAction.Priority\x94\x93\x94\x8c\xeNormalPriority\x94\x86\x94R\x94.) -shortcut= -shortcutContext=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0`\x80\x4\x95U\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.ShortcutContext\x94\x93\x94\x8c\xeWindowShortcut\x94\x86\x94R\x94.) -shortcutVisibleInContextMenu=false -statusTip= -text=Dock settings -toolTip=Dock settings -visible=true -whatsThis= - -[Perspectives] -1\Name=test -1\State="@ByteArray(\0\0\x1\xb3x\xdau\x90\x41O\xc3\x30\f\x85\xef\xfc\n+\xf7\xd1\xae\x12h\x87\x34\xd3V\xd8\x11\x98\xba\xb1sh\xcc\x14\xadMP\x92V\x3\xf1\xe3q\n\x8aV\x4\xa7\xd8\xcf/\xdfK\xcc\x97\xe7\xae\x85\x1\x9d\xd7\xd6\x94l~\x9d\x33@\xd3X\xa5\xcd\xb1\x64\xfb\xdd\x66\xb6`K\xc1\xb7\x61\xa5\x6i\x1aTw\xb6\x39\xd1\xac~\xf7\x1;xN\x17\x19\xec=\xba\xd4\x13\xa6\xb2&HmH\x89\x63\xc1S\xf\x9b\xd6\xca\x30\x6\xe4\xa4\xd7o\xad\xe\x81\xe4G\xa7\x91,a\x4|F@oB\xc9\n\xf2\xac\x1cJ\xd8\xc9\x97\x11\x5U\xef\x1c\xc6\xd1\x41\xe\xf8j]G\x8e\x83VG\f\xf0 ;\xbc\xd0\xa1j\xadG\x15\x83\x32\xc1\xb3\x88\x99\xc0\x8a\v\xd8\xfa\xbe\xda\xf6\xd8\xe3oX\xd2\xa7\xb0\x89\xe7\xc9z\x1d\xdf\x8dnm\xcf\x7f\xa7\xd6\xfa\x3\xbdX\xe4\x5\xcc\x8b\x9b[\xe0\xd9\xb7@\xe7\xcf\xff\xa9L+\xa2\xfa\x9f\x95\x8b\xab/_\xa2\x8f\x42)" -size=1 - -[mainWindow] -DockingState="@ByteArray(\0\0\x1\xb3x\xdau\x90\x41O\xc3\x30\f\x85\xef\xfc\n+\xf7\xd1\xae\x12h\x87\x34\xd3V\xd8\x11\x98\xba\xb1sh\xcc\x14\xadMP\x92V\x3\xf1\xe3q\n\x8aV\x4\xa7\xd8\xcf/\xdfK\xcc\x97\xe7\xae\x85\x1\x9d\xd7\xd6\x94l~\x9d\x33@\xd3X\xa5\xcd\xb1\x64\xfb\xdd\x66\xb6`K\xc1\xb7\x61\xa5\x6i\x1aTw\xb6\x39\xd1\xac~\xf7\x1;xN\x17\x19\xec=\xba\xd4\x13\xa6\xb2&HmH\x89\x63\xc1S\xf\x9b\xd6\xca\x30\x6\xe4\xa4\xd7o\xad\xe\x81\xe4G\xa7\x91,a\x4|F@oB\xc9\n\xf2\xac\x1cJ\xd8\xc9\x97\x11\x5U\xef\x1c\xc6\xd1\x41\xe\xf8j]G\x8e\x83VG\f\xf0 ;\xbc\xd0\xa1j\xadG\x15\x83\x32\xc1\xb3\x88\x99\xc0\x8a\v\xd8\xfa\xbe\xda\xf6\xd8\xe3oX\xd2\xa7\xb0\x89\xe7\xc9z\x1d\xdf\x8dnm\xcf\x7f\xa7\xd6\xfa\x3\xbdX\xe4\x5\xcc\x8b\x9b[\xe0\xd9\xb7@\xe7\xcf\xff\xa9L+\xa2\xfa\x9f\x95\x8b\xab/_\xa2\x8f\x42)" -Geometry=@ByteArray(\x1\xd9\xd0\xcb\0\x3\0\0\0\0\0\0\0\0\0\x1d\0\0\b\x1a\0\0\x3\xea\0\0\0\0\0\0\0\0\xff\xff\xff\xff\xff\xff\xff\xff\0\0\0\x1\0\0\0\0\xf\0\0\0\0\0\0\0\0\x1d\0\0\b\x1a\0\0\x3\xea) -State=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\xf\x80\x4\x95\x4\0\0\0\0\0\0\0\x43\0\x94.) - -[manifest] -widgets\1\closable=true -widgets\1\floatable=true -widgets\1\movable=true -widgets\1\object_name=BECQueue -widgets\1\widget_class=BECQueue -widgets\2\closable=true -widgets\2\floatable=true -widgets\2\movable=true -widgets\2\object_name=PositionerBox -widgets\2\widget_class=PositionerBox -widgets\3\closable=true -widgets\3\floatable=true -widgets\3\movable=true -widgets\3\object_name=Waveform -widgets\3\widget_class=Waveform -widgets\size=3 diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py index 616dcc08..bdfb9a5a 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py @@ -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() diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index d6563a33..6dfe76e5 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -1,28 +1,45 @@ # pylint: disable=missing-function-docstring, missing-module-docstring, unused-import +import base64 import os -import tempfile from unittest import mock from unittest.mock import MagicMock, patch import pytest -from qtpy.QtCore import QSettings +from qtpy.QtCore import QSettings, Qt +from qtpy.QtGui import QPixmap from qtpy.QtWidgets import QDialog, QMessageBox +import bec_widgets.widgets.containers.advanced_dock_area.profile_utils as profile_utils from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import ( AdvancedDockArea, DockSettingsDialog, SaveProfileDialog, ) from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( - is_profile_readonly, + default_profile_path, + get_profile_info, + is_profile_read_only, + is_quick_select, list_profiles, - open_settings, - profile_path, + load_default_profile_screenshot, + load_user_profile_screenshot, + open_default_settings, + open_user_settings, + plugin_profiles_dir, read_manifest, - set_profile_readonly, + restore_user_from_default, + set_quick_select, + user_profile_path, write_manifest, ) +from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import ( + PreviewPanel, + RestoreProfileDialog, +) +from bec_widgets.widgets.containers.advanced_dock_area.settings.workspace_manager import ( + WorkSpaceManager, +) from .client_mocks import mocked_client @@ -36,12 +53,96 @@ def advanced_dock_area(qtbot, mocked_client): yield widget +@pytest.fixture(autouse=True) +def isolate_profile_storage(tmp_path, monkeypatch): + """Ensure each test writes profiles into a unique temporary directory.""" + root = tmp_path / "profiles_root" + root.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(root)) + yield + + @pytest.fixture def temp_profile_dir(): - """Create a temporary directory for profile testing.""" - with tempfile.TemporaryDirectory() as temp_dir: - with patch.dict(os.environ, {"BECWIDGETS_PROFILE_DIR": temp_dir}): - yield temp_dir + """Return the current temporary profile directory.""" + return os.environ["BECWIDGETS_PROFILE_DIR"] + + +@pytest.fixture +def module_profile_factory(monkeypatch, tmp_path): + """Provide a helper to create synthetic module-level (read-only) profiles.""" + module_dir = tmp_path / "module_profiles" + module_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(profile_utils, "module_profiles_dir", lambda: str(module_dir)) + monkeypatch.setattr(profile_utils, "plugin_profiles_dir", lambda: None) + + def _create(name="readonly_profile", content="[profile]\n"): + path = module_dir / f"{name}.ini" + path.write_text(content) + return name + + return _create + + +@pytest.fixture +def workspace_manager_target(): + class _Signal: + def __init__(self): + self._slot = None + + def connect(self, slot): + self._slot = slot + + def emit(self, value): + if self._slot: + self._slot(value) + + class _Combo: + def __init__(self): + self.current_text = "" + + def setCurrentText(self, text): + self.current_text = text + + class _Action: + def __init__(self, widget): + self.widget = widget + + class _Components: + def __init__(self, combo): + self._combo = combo + + def get_action(self, name): + return _Action(self._combo) + + class _Toolbar: + def __init__(self, combo): + self.components = _Components(combo) + + class _Target: + def __init__(self): + self.profile_changed = _Signal() + self._combo = _Combo() + self.toolbar = _Toolbar(self._combo) + self._current_profile_name = None + self.load_profile_calls = [] + self.save_called = False + self.refresh_calls = 0 + + def load_profile(self, name): + self.load_profile_calls.append(name) + self._current_profile_name = name + + def save_profile(self): + self.save_called = True + + def _refresh_workspace_list(self): + self.refresh_calls += 1 + + def _factory(): + return _Target() + + return _factory class TestAdvancedDockAreaInit: @@ -81,7 +182,7 @@ class TestDockManagement: initial_count = len(advanced_dock_area.dock_list()) # Create a widget by string name - widget = advanced_dock_area.new("Waveform") + widget = advanced_dock_area.new("DarkModeButton") # Wait for the dock to be created (since it's async) qtbot.wait(200) @@ -430,7 +531,6 @@ class TestSaveProfileDialog: assert dialog.windowTitle() == "Save Workspace Profile" assert dialog.isModal() assert dialog.name_edit.text() == "test_profile" - assert hasattr(dialog, "readonly_checkbox") def test_save_profile_dialog_get_values(self, qtbot): """Test getting values from SaveProfileDialog.""" @@ -438,10 +538,10 @@ class TestSaveProfileDialog: qtbot.addWidget(dialog) dialog.name_edit.setText("my_profile") - dialog.readonly_checkbox.setChecked(True) + dialog.quick_select_checkbox.setChecked(True) assert dialog.get_profile_name() == "my_profile" - assert dialog.is_readonly() is True + assert dialog.is_quick_select() is True def test_save_button_enabled_state(self, qtbot): """Test save button is enabled/disabled based on name input.""" @@ -459,56 +559,568 @@ class TestSaveProfileDialog: dialog.name_edit.setText("") assert not dialog.save_btn.isEnabled() + def test_accept_blocks_empty_name(self, qtbot): + dialog = SaveProfileDialog(None) + qtbot.addWidget(dialog) + dialog.name_edit.clear() + + dialog.accept() + + assert dialog.result() == QDialog.Rejected + assert dialog.overwrite_existing is False + + def test_accept_readonly_suggests_unique_name(self, qtbot, monkeypatch): + info_calls = [] + monkeypatch.setattr( + QMessageBox, + "information", + lambda *args, **kwargs: info_calls.append((args, kwargs)) or QMessageBox.Ok, + ) + + dialog = SaveProfileDialog( + None, + name_exists=lambda name: name == "readonly_custom", + profile_origin=lambda name: "module" if name == "readonly" else "unknown", + origin_label=lambda name: "ModuleDefaults", + ) + qtbot.addWidget(dialog) + dialog.name_edit.setText("readonly") + + dialog.accept() + + assert dialog.result() == QDialog.Rejected + assert dialog.name_edit.text().startswith("readonly_custom") + assert dialog.overwrite_checkbox.isChecked() is False + assert info_calls, "Expected informational prompt for read-only profile" + + def test_accept_existing_profile_confirm_yes(self, qtbot, monkeypatch): + monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.Yes) + + dialog = SaveProfileDialog( + None, + current_profile_name="profile_a", + name_exists=lambda name: name == "profile_a", + profile_origin=lambda name: "settings" if name == "profile_a" else "unknown", + ) + qtbot.addWidget(dialog) + dialog.name_edit.setText("profile_a") + + dialog.accept() + + assert dialog.result() == QDialog.Accepted + assert dialog.overwrite_existing is True + + def test_accept_existing_profile_confirm_no(self, qtbot, monkeypatch): + monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.No) + + dialog = SaveProfileDialog( + None, + current_profile_name="profile_a", + name_exists=lambda name: False, + profile_origin=lambda name: "settings" if name == "profile_a" else "unknown", + ) + qtbot.addWidget(dialog) + dialog.name_edit.setText("profile_a") + + dialog.accept() + + assert dialog.result() == QDialog.Rejected + assert dialog.name_edit.text().startswith("profile_a_custom") + assert dialog.overwrite_existing is False + assert dialog.overwrite_checkbox.isChecked() is False + + def test_overwrite_toggle_sets_and_restores_name(self, qtbot): + dialog = SaveProfileDialog( + None, current_name="custom_name", current_profile_name="existing_profile" + ) + qtbot.addWidget(dialog) + + dialog.overwrite_checkbox.setChecked(True) + assert dialog.name_edit.text() == "existing_profile" + dialog.name_edit.setText("existing_profile") + dialog.overwrite_checkbox.setChecked(False) + assert dialog.name_edit.text() == "custom_name" + + +class TestPreviewPanel: + """Test preview panel scaling behavior.""" + + def test_preview_panel_without_pixmap(self, qtbot): + panel = PreviewPanel("Current", None) + qtbot.addWidget(panel) + assert "No preview available" in panel.image_label.text() + + def test_preview_panel_with_pixmap(self, qtbot): + pixmap = QPixmap(40, 20) + pixmap.fill(Qt.red) + panel = PreviewPanel("Current", pixmap) + qtbot.addWidget(panel) + assert panel.image_label.pixmap() is not None + + def test_preview_panel_set_pixmap_resets_placeholder(self, qtbot): + panel = PreviewPanel("Current", None) + qtbot.addWidget(panel) + pixmap = QPixmap(30, 30) + pixmap.fill(Qt.blue) + panel.setPixmap(pixmap) + assert panel.image_label.pixmap() is not None + panel.setPixmap(None) + assert panel.image_label.pixmap() is None or panel.image_label.pixmap().isNull() + assert "No preview available" in panel.image_label.text() + + +class TestRestoreProfileDialog: + """Test restore dialog confirmation flow.""" + + def test_confirm_accepts(self, monkeypatch): + monkeypatch.setattr(RestoreProfileDialog, "exec", lambda self: QDialog.Accepted) + assert RestoreProfileDialog.confirm(None, QPixmap(), QPixmap()) is True + + def test_confirm_rejects(self, monkeypatch): + monkeypatch.setattr(RestoreProfileDialog, "exec", lambda self: QDialog.Rejected) + assert RestoreProfileDialog.confirm(None, QPixmap(), QPixmap()) is False + + +class TestProfileInfoAndScreenshots: + """Tests for profile utilities metadata and screenshot helpers.""" + + PNG_BYTES = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAAFUlEQVQYlWP8//8/A27AhEduBEsDAKXjAxHmByO3AAAAAElFTkSuQmCC" + ) + + def _write_manifest(self, settings, count=2): + settings.beginWriteArray(profile_utils.SETTINGS_KEYS["manifest"], count) + for i in range(count): + settings.setArrayIndex(i) + settings.setValue("object_name", f"widget_{i}") + settings.setValue("widget_class", "Dummy") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + def test_get_profile_info_user_origin(self, temp_profile_dir): + name = "info_user" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["created_at"], "2023-01-01T00:00:00Z") + settings.setValue("profile/author", "Custom") + set_quick_select(name, True) + self._write_manifest(settings, count=3) + + info = get_profile_info(name) + + assert info.name == name + assert info.origin == "settings" + assert info.is_read_only is False + assert info.is_quick_select is True + assert info.widget_count == 3 + assert info.author == "User" + assert info.user_path.endswith(f"{name}.ini") + assert info.size_kb >= 0 + + def test_get_profile_info_default_only(self, temp_profile_dir): + name = "info_default" + settings = open_default_settings(name) + self._write_manifest(settings, count=1) + + user_path = user_profile_path(name) + if os.path.exists(user_path): + os.remove(user_path) + + info = get_profile_info(name) + + assert info.origin == "settings" + assert info.user_path.endswith(f"{name}.ini") + assert info.widget_count == 1 + + def test_get_profile_info_module_readonly(self, module_profile_factory): + name = module_profile_factory("info_readonly") + info = get_profile_info(name) + assert info.origin == "module" + assert info.is_read_only is True + assert info.author == "BEC Widgets" + + def test_get_profile_info_unknown_profile(self): + name = "nonexistent_profile" + if os.path.exists(user_profile_path(name)): + os.remove(user_profile_path(name)) + if os.path.exists(default_profile_path(name)): + os.remove(default_profile_path(name)) + + info = get_profile_info(name) + + assert info.origin == "unknown" + assert info.is_read_only is False + assert info.widget_count == 0 + + def test_load_user_profile_screenshot(self, temp_profile_dir): + name = "user_screenshot" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) + settings.sync() + + pix = load_user_profile_screenshot(name) + + assert pix is not None and not pix.isNull() + + def test_load_default_profile_screenshot(self, temp_profile_dir): + name = "default_screenshot" + settings = open_default_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) + settings.sync() + + pix = load_default_profile_screenshot(name) + + assert pix is not None and not pix.isNull() + + def test_load_screenshot_from_settings_invalid(self, temp_profile_dir): + name = "invalid_screenshot" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], "not-an-image") + settings.sync() + + pix = profile_utils._load_screenshot_from_settings(settings) + + assert pix is None + + def test_load_screenshot_from_settings_bytes(self, temp_profile_dir): + name = "bytes_screenshot" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) + settings.sync() + + pix = profile_utils._load_screenshot_from_settings(settings) + + assert pix is not None and not pix.isNull() + + +class TestWorkSpaceManager: + """Test workspace manager interactions.""" + + @staticmethod + def _create_profiles(names): + for name in names: + settings = open_user_settings(name) + settings.setValue("meta", "value") + settings.sync() + + def test_render_table_populates_rows(self, qtbot): + profile_names = ["profile_a", "profile_b"] + self._create_profiles(profile_names) + + manager = WorkSpaceManager(target_widget=None) + qtbot.addWidget(manager) + + assert manager.profile_table.rowCount() >= len(profile_names) + + def test_switch_profile_updates_target(self, qtbot, workspace_manager_target): + name = "profile_switch" + self._create_profiles([name]) + target = workspace_manager_target() + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + manager.switch_profile(name) + + assert target.load_profile_calls == [name] + assert target._combo.current_text == name + assert manager._current_selected_profile() == name + + def test_toggle_quick_select_updates_flag(self, qtbot, workspace_manager_target): + name = "profile_toggle" + self._create_profiles([name]) + target = workspace_manager_target() + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + initial = is_quick_select(name) + manager.toggle_quick_select(name) + + assert is_quick_select(name) is (not initial) + assert target.refresh_calls >= 1 + + def test_save_current_as_profile_with_target(self, qtbot, workspace_manager_target): + name = "profile_save" + self._create_profiles([name]) + target = workspace_manager_target() + target._current_profile_name = name + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + manager.save_current_as_profile() + + assert target.save_called is True + assert manager._current_selected_profile() == name + + def test_delete_profile_removes_files(self, qtbot, workspace_manager_target, monkeypatch): + name = "profile_delete" + self._create_profiles([name]) + target = workspace_manager_target() + target._current_profile_name = name + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + monkeypatch.setattr(QMessageBox, "question", lambda *a, **k: QMessageBox.Yes) + + manager.delete_profile(name) + + assert not os.path.exists(user_profile_path(name)) + assert target.refresh_calls >= 1 + + def test_delete_readonly_profile_shows_message( + self, qtbot, workspace_manager_target, module_profile_factory, monkeypatch + ): + readonly = module_profile_factory("readonly_delete") + list_profiles() + info_calls = [] + monkeypatch.setattr( + QMessageBox, + "information", + lambda *args, **kwargs: info_calls.append((args, kwargs)) or QMessageBox.Ok, + ) + manager = WorkSpaceManager(target_widget=workspace_manager_target()) + qtbot.addWidget(manager) + + manager.delete_profile(readonly) + + assert info_calls, "Expected informational prompt for read-only profile" + + +class TestAdvancedDockAreaRestoreAndDialogs: + """Additional coverage for restore flows and workspace dialogs.""" + + def test_restore_user_profile_from_default_confirm_true(self, advanced_dock_area, monkeypatch): + profile_name = "profile_restore_true" + open_default_settings(profile_name).sync() + open_user_settings(profile_name).sync() + advanced_dock_area._current_profile_name = profile_name + advanced_dock_area.isVisible = lambda: False + pix = QPixmap(8, 8) + pix.fill(Qt.red) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot", + lambda name: pix, + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot", + lambda name: pix, + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm", + lambda *args, **kwargs: True, + ) + + with ( + patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.restore_user_from_default" + ) as mock_restore, + patch.object(advanced_dock_area, "delete_all") as mock_delete_all, + patch.object(advanced_dock_area, "load_profile") as mock_load_profile, + ): + advanced_dock_area.restore_user_profile_from_default() + + mock_restore.assert_called_once_with(profile_name) + mock_delete_all.assert_called_once() + mock_load_profile.assert_called_once_with(profile_name) + + def test_restore_user_profile_from_default_confirm_false(self, advanced_dock_area, monkeypatch): + profile_name = "profile_restore_false" + open_default_settings(profile_name).sync() + open_user_settings(profile_name).sync() + advanced_dock_area._current_profile_name = profile_name + advanced_dock_area.isVisible = lambda: False + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot", + lambda name: QPixmap(), + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot", + lambda name: QPixmap(), + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm", + lambda *args, **kwargs: False, + ) + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.restore_user_from_default" + ) as mock_restore: + advanced_dock_area.restore_user_profile_from_default() + + mock_restore.assert_not_called() + + def test_restore_user_profile_from_default_no_target(self, advanced_dock_area, monkeypatch): + advanced_dock_area._current_profile_name = None + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm" + ) as mock_confirm: + advanced_dock_area.restore_user_profile_from_default() + mock_confirm.assert_not_called() + + def test_refresh_workspace_list_with_refresh_profiles(self, advanced_dock_area): + profile_name = "refresh_profile" + open_user_settings(profile_name).sync() + advanced_dock_area._current_profile_name = profile_name + combo = advanced_dock_area.toolbar.components.get_action("workspace_combo").widget + combo.refresh_profiles = MagicMock() + + advanced_dock_area._refresh_workspace_list() + + combo.refresh_profiles.assert_called_once_with(profile_name) + + def test_refresh_workspace_list_fallback(self, advanced_dock_area): + class ComboStub: + def __init__(self): + self.items = [] + self.tooltip = "" + self.block_calls = [] + self.cleared = False + self.current_index = -1 + + def blockSignals(self, value): + self.block_calls.append(value) + + def clear(self): + self.items.clear() + self.cleared = True + + def addItems(self, items): + self.items.extend(items) + + def findText(self, text): + try: + return self.items.index(text) + except ValueError: + return -1 + + def setCurrentIndex(self, idx): + self.current_index = idx + + def setToolTip(self, text): + self.tooltip = text + + active = "active_profile" + quick = "quick_profile" + open_user_settings(active).sync() + open_user_settings(quick).sync() + set_quick_select(quick, True) + + combo_stub = ComboStub() + + class StubAction: + def __init__(self, widget): + self.widget = widget + + with patch.object( + advanced_dock_area.toolbar.components, "get_action", return_value=StubAction(combo_stub) + ): + advanced_dock_area._current_profile_name = active + advanced_dock_area._refresh_workspace_list() + + assert combo_stub.block_calls == [True, False] + assert combo_stub.items[0] == active + assert combo_stub.tooltip == "Active profile is not in quick select" + + def test_show_workspace_manager_creates_dialog(self, qtbot, advanced_dock_area): + action = advanced_dock_area.toolbar.components.get_action("manage_workspaces").action + assert not action.isChecked() + + advanced_dock_area._current_profile_name = "manager_profile" + open_user_settings("manager_profile").sync() + + advanced_dock_area.show_workspace_manager() + + assert advanced_dock_area.manage_dialog is not None + assert advanced_dock_area.manage_dialog.isVisible() + assert action.isChecked() + assert isinstance(advanced_dock_area.manage_widget, WorkSpaceManager) + + advanced_dock_area.manage_dialog.close() + qtbot.waitUntil(lambda: advanced_dock_area.manage_dialog is None) + assert not action.isChecked() + + def test_manage_dialog_closed(self, advanced_dock_area): + widget_mock = MagicMock() + dialog_mock = MagicMock() + advanced_dock_area.manage_widget = widget_mock + advanced_dock_area.manage_dialog = dialog_mock + action = advanced_dock_area.toolbar.components.get_action("manage_workspaces").action + action.setChecked(True) + + advanced_dock_area._manage_dialog_closed() + + widget_mock.close.assert_called_once() + widget_mock.deleteLater.assert_called_once() + dialog_mock.deleteLater.assert_called_once() + assert advanced_dock_area.manage_dialog is None + assert not action.isChecked() + class TestProfileManagement: """Test profile management functionality.""" def test_profile_path(self, temp_profile_dir): """Test profile path generation.""" - path = profile_path("test_profile") - expected = os.path.join(temp_profile_dir, "test_profile.ini") + path = user_profile_path("test_profile") + expected = os.path.join(temp_profile_dir, "user", "test_profile.ini") assert path == expected + default_path = default_profile_path("test_profile") + expected_default = os.path.join(temp_profile_dir, "default", "test_profile.ini") + assert default_path == expected_default + def test_open_settings(self, temp_profile_dir): """Test opening settings for a profile.""" - settings = open_settings("test_profile") + settings = open_user_settings("test_profile") assert isinstance(settings, QSettings) def test_list_profiles_empty(self, temp_profile_dir): """Test listing profiles when directory is empty.""" + try: + module_defaults = { + os.path.splitext(f)[0] + for f in os.listdir(profile_utils.module_profiles_dir()) + if f.endswith(".ini") + } + except FileNotFoundError: + module_defaults = set() profiles = list_profiles() - assert profiles == [] + assert module_defaults.issubset(set(profiles)) def test_list_profiles_with_files(self, temp_profile_dir): """Test listing profiles with existing files.""" # Create some test profile files profile_names = ["profile1", "profile2", "profile3"] for name in profile_names: - settings = open_settings(name) + settings = open_user_settings(name) settings.setValue("test", "value") settings.sync() profiles = list_profiles() - assert sorted(profiles) == sorted(profile_names) + for name in profile_names: + assert name in profiles - def test_readonly_profile_operations(self, temp_profile_dir): + def test_readonly_profile_operations(self, temp_profile_dir, module_profile_factory): """Test read-only profile functionality.""" - profile_name = "readonly_profile" + profile_name = "user_profile" # Initially should not be read-only - assert not is_profile_readonly(profile_name) + assert not is_profile_read_only(profile_name) - # Set as read-only - set_profile_readonly(profile_name, True) - assert is_profile_readonly(profile_name) + # Create a user profile and ensure it's writable + settings = open_user_settings(profile_name) + settings.setValue("test", "value") + settings.sync() + assert not is_profile_read_only(profile_name) - # Unset read-only - set_profile_readonly(profile_name, False) - assert not is_profile_readonly(profile_name) + # Verify a bundled module profile is detected as read-only + readonly_name = module_profile_factory("module_default") + assert is_profile_read_only(readonly_name) def test_write_and_read_manifest(self, temp_profile_dir, advanced_dock_area, qtbot): """Test writing and reading dock manifest.""" - settings = open_settings("test_manifest") + settings = open_user_settings("test_manifest") # Create real docks advanced_dock_area.new("DarkModeButton") @@ -535,44 +1147,65 @@ class TestProfileManagement: assert "floatable" in item assert "movable" in item + def test_restore_preserves_quick_select(self, temp_profile_dir): + """Ensure restoring keeps the quick select flag when it was enabled.""" + profile_name = "restorable_profile" + default_settings = open_default_settings(profile_name) + default_settings.setValue("test", "default") + default_settings.sync() + + user_settings = open_user_settings(profile_name) + user_settings.setValue("test", "user") + user_settings.sync() + + set_quick_select(profile_name, True) + assert is_quick_select(profile_name) + + restore_user_from_default(profile_name) + + assert is_quick_select(profile_name) + class TestWorkspaceProfileOperations: """Test workspace profile save/load/delete operations.""" - def test_save_profile_readonly_conflict(self, advanced_dock_area, temp_profile_dir): + def test_save_profile_readonly_conflict( + self, advanced_dock_area, temp_profile_dir, module_profile_factory + ): """Test saving profile when read-only profile exists.""" - profile_name = "readonly_profile" + profile_name = module_profile_factory("readonly_profile") + new_profile = f"{profile_name}_custom" + target_path = user_profile_path(new_profile) + if os.path.exists(target_path): + os.remove(target_path) - # Create a read-only profile - set_profile_readonly(profile_name, True) - settings = open_settings(profile_name) - settings.setValue("test", "value") - settings.sync() + class StubDialog: + def __init__(self, *args, **kwargs): + self.overwrite_existing = False + + def exec(self): + return QDialog.Accepted + + def get_profile_name(self): + return new_profile + + def is_quick_select(self): + return False with patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog" - ) as mock_dialog_class: - mock_dialog = MagicMock() - mock_dialog.exec.return_value = QDialog.Accepted - mock_dialog.get_profile_name.return_value = profile_name - mock_dialog.is_readonly.return_value = False - mock_dialog_class.return_value = mock_dialog + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog", + StubDialog, + ): + advanced_dock_area.save_profile(profile_name) - with patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.warning" - ) as mock_warning: - mock_warning.return_value = QMessageBox.No - - advanced_dock_area.save_profile() - - mock_warning.assert_called_once() + assert os.path.exists(target_path) def test_load_profile_with_manifest(self, advanced_dock_area, temp_profile_dir, qtbot): """Test loading profile with widget manifest.""" profile_name = "test_load_profile" # Create a profile with manifest - settings = open_settings(profile_name) + settings = open_user_settings(profile_name) settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", "test_widget") @@ -583,8 +1216,6 @@ class TestWorkspaceProfileOperations: settings.endArray() settings.sync() - initial_count = len(advanced_dock_area.widget_map()) - # Load profile advanced_dock_area.load_profile(profile_name) @@ -595,15 +1226,96 @@ class TestWorkspaceProfileOperations: widget_map = advanced_dock_area.widget_map() assert "test_widget" in widget_map - def test_delete_profile_readonly(self, advanced_dock_area, temp_profile_dir): - """Test deleting read-only profile shows warning.""" - profile_name = "readonly_profile" + def test_save_as_skips_autosave_source_profile( + self, advanced_dock_area, temp_profile_dir, qtbot + ): + """Saving a new profile avoids overwriting the source profile during the switch.""" + source_profile = "autosave_source" + new_profile = "autosave_new" - # Create read-only profile - set_profile_readonly(profile_name, True) - settings = open_settings(profile_name) + settings = open_user_settings(source_profile) + settings.beginWriteArray("manifest/widgets", 1) + settings.setArrayIndex(0) + settings.setValue("object_name", "source_widget") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + advanced_dock_area.load_profile(source_profile) + qtbot.wait(500) + advanced_dock_area.new("DarkModeButton") + qtbot.wait(500) + + class StubDialog: + def __init__(self, *args, **kwargs): + self.overwrite_existing = False + + def exec(self): + return QDialog.Accepted + + def get_profile_name(self): + return new_profile + + def is_quick_select(self): + return False + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog", + StubDialog, + ): + advanced_dock_area.save_profile() + + qtbot.wait(500) + source_manifest = read_manifest(open_user_settings(source_profile)) + new_manifest = read_manifest(open_user_settings(new_profile)) + + assert len(source_manifest) == 1 + assert len(new_manifest) == 2 + + def test_switch_autosaves_previous_profile(self, advanced_dock_area, temp_profile_dir, qtbot): + """Regular profile switches should persist the outgoing layout.""" + profile_a = "autosave_keep" + profile_b = "autosave_target" + + for profile in (profile_a, profile_b): + settings = open_user_settings(profile) + settings.beginWriteArray("manifest/widgets", 1) + settings.setArrayIndex(0) + settings.setValue("object_name", f"{profile}_widget") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + advanced_dock_area.load_profile(profile_a) + qtbot.wait(500) + advanced_dock_area.new("DarkModeButton") + qtbot.wait(500) + + advanced_dock_area.load_profile(profile_b) + qtbot.wait(500) + + manifest_a = read_manifest(open_user_settings(profile_a)) + assert len(manifest_a) == 2 + + def test_delete_profile_readonly( + self, advanced_dock_area, temp_profile_dir, module_profile_factory + ): + """Test deleting bundled profile removes only the writable copy.""" + profile_name = module_profile_factory("readonly_profile") + list_profiles() # ensure default and user copies are materialized + settings = open_user_settings(profile_name) settings.setValue("test", "value") settings.sync() + user_path = user_profile_path(profile_name) + default_path = default_profile_path(profile_name) + assert os.path.exists(user_path) + assert os.path.exists(default_path) with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: mock_combo = MagicMock() @@ -611,22 +1323,27 @@ class TestWorkspaceProfileOperations: mock_get_action.return_value.widget = mock_combo with patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.warning" - ) as mock_warning: + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question" + ) as mock_question: + mock_question.return_value = QMessageBox.Yes + advanced_dock_area.delete_profile() - mock_warning.assert_called_once() - # Profile should still exist - assert os.path.exists(profile_path(profile_name)) + mock_question.assert_called_once() + # User copy should be removed, default remains + assert not os.path.exists(user_path) + assert os.path.exists(default_path) def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): """Test successful profile deletion.""" profile_name = "deletable_profile" # Create regular profile - settings = open_settings(profile_name) + settings = open_user_settings(profile_name) settings.setValue("test", "value") settings.sync() + user_path = user_profile_path(profile_name) + assert os.path.exists(user_path) with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: mock_combo = MagicMock() @@ -644,13 +1361,13 @@ class TestWorkspaceProfileOperations: mock_question.assert_called_once() mock_refresh.assert_called_once() # Profile should be deleted - assert not os.path.exists(profile_path(profile_name)) + assert not os.path.exists(user_path) def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir): """Test refreshing workspace list.""" # Create some profiles for name in ["profile1", "profile2"]: - settings = open_settings(name) + settings = open_user_settings(name) settings.setValue("test", "value") settings.sync()