diff --git a/bec_widgets/widgets/containers/dock_area/profile_utils.py b/bec_widgets/widgets/containers/dock_area/profile_utils.py index 396849d7..53564b6f 100644 --- a/bec_widgets/widgets/containers/dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/dock_area/profile_utils.py @@ -4,6 +4,8 @@ Utilities for managing BECDockArea profiles stored in INI files. Policy: - All created/modified profiles are stored under the BEC settings root: /profiles/{baseline,runtime} +- App-wide dock-area metadata and feature flags are stored in + /profiles/_meta.ini. - Bundled read-only baselines are discovered in BW core profiles and plugin bec_widgets/profiles but never written to. - Lookup order when reading: runtime → settings baseline → app or plugin bundled baseline. @@ -463,6 +465,7 @@ SETTINGS_KEYS = { "manifest": "manifest/widgets", "created_at": "profile/created_at", "is_quick_select": "profile/quick_select", + "experimental_features": "app/experimental_features", "screenshot": "profile/screenshot", "screenshot_at": "profile/screenshot_at", "last_profile": "app/last_profile", @@ -661,6 +664,28 @@ def set_last_profile( s.remove(key) +def is_experimental_features_enabled() -> bool: + """ + Check whether experimental features are enabled globally. + + Returns: + bool: ``True`` when the global experimental flag is enabled. + """ + s = _app_settings() + return s.value(SETTINGS_KEYS["experimental_features"], False, type=bool) + + +def set_experimental_features_enabled(enabled: bool) -> None: + """ + Persist the global experimental-features flag. + + Args: + enabled (bool): ``True`` to enable experimental features, ``False`` to disable. + """ + s = _app_settings() + s.setValue(SETTINGS_KEYS["experimental_features"], bool(enabled)) + + def now_iso_utc() -> str: """ Return the current UTC timestamp formatted in ISO 8601. diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index d8d25228..72468fe1 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -12,6 +12,7 @@ from qtpy.QtWidgets import ( QHBoxLayout, QLabel, QMainWindow, + QMessageBox, QStyle, QVBoxLayout, QWidget, @@ -22,6 +23,10 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.ui_loader import UILoader +from bec_widgets.widgets.containers.dock_area.profile_utils import ( + is_experimental_features_enabled, + set_experimental_features_enabled, +) from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import ( BECNotificationBroker, @@ -343,6 +348,12 @@ class BECMainWindow(BECWidget, QMainWindow): widget_tree_action.triggered.connect(self._show_widget_hierarchy_dialog) theme_menu.addAction(widget_tree_action) + experimental_features_action = QAction("Enable Experimental Features", self, checkable=True) + experimental_features_action.setChecked(is_experimental_features_enabled()) + experimental_features_action.toggled.connect(self._toggle_experimental_features) + theme_menu.addAction(experimental_features_action) + self._experimental_features_action = experimental_features_action + # Set the default theme if hasattr(self.app, "theme") and self.app.theme: theme_name = self.app.theme.theme.lower() @@ -417,6 +428,19 @@ class BECMainWindow(BECWidget, QMainWindow): """ apply_theme(theme) # emits theme_updated and applies palette globally + @SafeSlot(bool) + def _toggle_experimental_features(self, enabled: bool): + set_experimental_features_enabled(enabled) + reply = QMessageBox.question( + self, + "Restart Required", + "Experimental features require a restart to take effect. Restart the application now?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self.app.quit() + def _show_widget_hierarchy_dialog(self): if self._widget_hierarchy_dialog is None: dialog = WidgetHierarchyDialog(root_widget=None, parent=self) diff --git a/tests/unit_tests/test_dock_area.py b/tests/unit_tests/test_dock_area.py index 5739fa22..637b685b 100644 --- a/tests/unit_tests/test_dock_area.py +++ b/tests/unit_tests/test_dock_area.py @@ -22,6 +22,7 @@ from bec_widgets.widgets.containers.dock_area.profile_utils import ( SETTINGS_KEYS, baseline_profile_path, get_profile_info, + is_experimental_features_enabled, is_profile_read_only, is_quick_select, list_profiles, @@ -32,6 +33,7 @@ from bec_widgets.widgets.containers.dock_area.profile_utils import ( read_manifest, restore_runtime_from_baseline, runtime_profile_path, + set_experimental_features_enabled, set_quick_select, write_manifest, ) @@ -1443,6 +1445,20 @@ class TestWorkSpaceManager: assert is_quick_select(name) is (not initial) assert target.refresh_calls >= 1 + def test_global_experimental_features_flag_persists(self): + assert is_experimental_features_enabled() is False + + set_experimental_features_enabled(True) + + assert is_experimental_features_enabled() is True + meta_path = os.path.join(os.environ["BECWIDGETS_PROFILE_DIR"], "_meta.ini") + assert ( + QSettings(meta_path, QSettings.IniFormat).value( + SETTINGS_KEYS["experimental_features"], type=bool + ) + is True + ) + def test_save_current_as_profile_with_target(self, qtbot, workspace_manager_target): name = "profile_save" self._create_profiles([name]) diff --git a/tests/unit_tests/test_main_widnow.py b/tests/unit_tests/test_main_widnow.py index c22a4eeb..3382dfc1 100644 --- a/tests/unit_tests/test_main_widnow.py +++ b/tests/unit_tests/test_main_widnow.py @@ -1,12 +1,14 @@ +import os import webbrowser from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest -from qtpy.QtCore import QEvent, QPoint, QPointF +from qtpy.QtCore import QEvent, QPoint, QPointF, QSettings from qtpy.QtGui import QEnterEvent -from qtpy.QtWidgets import QApplication, QFrame, QLabel +from qtpy.QtWidgets import QApplication, QFrame, QLabel, QMessageBox +from bec_widgets.widgets.containers.dock_area.profile_utils import SETTINGS_KEYS from bec_widgets.widgets.containers.main_window.addons.hover_widget import ( HoverWidget, WidgetTooltip, @@ -112,6 +114,35 @@ def test_hidden_scan_progress_parent_blocks_children_namespace(bec_main_window): assert nested_progress.parent_id == hidden_progress.gui_id +def test_experimental_features_menu_action_persists_flag( + monkeypatch, tmp_path, qtbot, mocked_client +): + monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(tmp_path)) + widget = BECMainWindow(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + action = widget._experimental_features_action + assert action.isCheckable() + assert action.isChecked() is False + + quit_calls = [] + monkeypatch.setattr(widget.app, "quit", lambda: quit_calls.append(True)) + monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.Yes) + + action.setChecked(True) + action.setChecked(False) + + meta_path = os.path.join(str(tmp_path), "_meta.ini") + assert ( + QSettings(meta_path, QSettings.Format.IniFormat).value( + SETTINGS_KEYS["experimental_features"], type=bool + ) + is False + ) + assert quit_calls == [True, True] + + ################################################################# # Tests for BECMainWindow Addons #################################################################