diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index a80fea65..0d62ef29 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -1,3 +1,5 @@ +from bec_qthemes import material_icon +from qtpy.QtGui import QAction # type: ignore from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION @@ -8,6 +10,7 @@ from bec_widgets.applications.views.device_manager_view.device_manager_view impo from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup from bec_widgets.utils.colors import apply_theme +from bec_widgets.utils.guided_tour import GuidedTour from bec_widgets.utils.screen_utils import ( apply_centered_size, available_screen_geometry, @@ -50,6 +53,10 @@ class BECMainApp(BECMainWindow): self._add_views() + # Initialize guided tour + self.guided_tour = GuidedTour(self) + self._setup_guided_tour() + def _add_views(self): self.add_section("BEC Applications", "bec_apps") self.dock_area = DockAreaView(self) @@ -105,6 +112,9 @@ class BECMainApp(BECMainWindow): self.set_current("dock_area") self.sidebar.add_dark_mode_item() + # Add guided tour to Help menu + self._add_guided_tour_to_menu() + # --- Public API ------------------------------------------------------ def add_section(self, title: str, id: str, position: int | None = None): return self.sidebar.add_section(title, id, position) @@ -200,6 +210,125 @@ class BECMainApp(BECMainWindow): if hasattr(new_view, "on_enter"): new_view.on_enter() + def _setup_guided_tour(self): + """ + Setup the guided tour for the main application. + Registers key UI components and delegates to views for their internal components. + """ + tour_steps = [] + + # --- General Layout Components --- + + # Register the sidebar toggle button + toggle_step = self.guided_tour.register_widget( + widget=self.sidebar.toggle, + title="Sidebar Toggle", + text="Click this button to expand or collapse the sidebar. When expanded, you can see full navigation item titles and section names.", + ) + tour_steps.append(toggle_step) + + # Register the dark mode toggle + dark_mode_item = self.sidebar.components.get("dark_mode") + if dark_mode_item: + dark_mode_step = self.guided_tour.register_widget( + widget=dark_mode_item, + title="Theme Toggle", + text="Switch between light and dark themes. The theme preference is saved and will be applied when you restart the application.", + ) + tour_steps.append(dark_mode_step) + + # Register the client info label + if hasattr(self, "_client_info_hover"): + client_info_step = self.guided_tour.register_widget( + widget=self._client_info_hover, + title="Client Status", + text="Displays status messages and information from the BEC Server.", + ) + tour_steps.append(client_info_step) + + # Register the scan progress bar if available + if hasattr(self, "_scan_progress_hover"): + progress_step = self.guided_tour.register_widget( + widget=self._scan_progress_hover, + title="Scan Progress", + text="Monitor the progress of ongoing scans. Hover over the progress bar to see detailed information including elapsed time and estimated completion.", + ) + tour_steps.append(progress_step) + + # Register the notification indicator in the status bar + if hasattr(self, "notification_indicator"): + notif_step = self.guided_tour.register_widget( + widget=self.notification_indicator, + title="Notification Center", + text="View system notifications, errors, and status updates. Click to filter notifications by type or expand to see all details.", + ) + tour_steps.append(notif_step) + + # --- View-Specific Components --- + + # Register all views that can extend the tour + for view_id, view_index in self._view_index.items(): + view_widget = self.stack.widget(view_index) + if not view_widget or not hasattr(view_widget, "register_tour_steps"): + continue + + # Get the view's tour steps + view_tour = view_widget.register_tour_steps(self.guided_tour, self) + if view_tour is None: + if hasattr(view_widget.content, "register_tour_steps"): + view_tour = view_widget.content.register_tour_steps(self.guided_tour, self) + if view_tour is None: + continue + + # Get the corresponding sidebar navigation item + nav_item = self.sidebar.components.get(view_id) + if not nav_item: + continue + + # Use the view's title for the navigation button + nav_step = self.guided_tour.register_widget( + widget=nav_item, + title=view_tour.view_title, + text=f"Navigate to the {view_tour.view_title} to access its features and functionality.", + ) + tour_steps.append(nav_step) + tour_steps.extend(view_tour.step_ids) + + # Create the tour with all registered steps + if tour_steps: + self.guided_tour.create_tour(tour_steps) + + def start_guided_tour(self): + """ + Public method to start the guided tour. + This can be called programmatically or connected to a menu/button action. + """ + self.guided_tour.start_tour() + + def _add_guided_tour_to_menu(self): + """ + Add a 'Guided Tour' action to the Help menu. + """ + + # Find the Help menu + menu_bar = self.menuBar() + help_menu = None + for action in menu_bar.actions(): + if action.text() == "Help": + help_menu = action.menu() + break + + if help_menu: + # Add separator before the tour action + help_menu.addSeparator() + + # Create and add the guided tour action + tour_action = QAction("Start Guided Tour", self) + tour_action.setIcon(material_icon("help")) + tour_action.triggered.connect(self.start_guided_tour) + tour_action.setShortcut("F1") # Add keyboard shortcut + help_menu.addAction(tour_action) + def main(): # pragma: no cover """ diff --git a/bec_widgets/applications/views/developer_view/developer_view.py b/bec_widgets/applications/views/developer_view/developer_view.py index 6f177c75..aef9a8e3 100644 --- a/bec_widgets/applications/views/developer_view/developer_view.py +++ b/bec_widgets/applications/views/developer_view/developer_view.py @@ -1,7 +1,7 @@ from qtpy.QtWidgets import QWidget from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget -from bec_widgets.applications.views.view import ViewBase +from bec_widgets.applications.views.view import ViewBase, ViewTourSteps class DeveloperView(ViewBase): @@ -21,6 +21,81 @@ class DeveloperView(ViewBase): self.developer_widget = DeveloperWidget(parent=self) self.set_content(self.developer_widget) + def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None: + """Register Developer View components with the guided tour. + + Args: + guided_tour: The GuidedTour instance to register with. + main_app: The main application instance (for accessing set_current). + + Returns: + ViewTourSteps | None: Model containing view title and step IDs. + """ + step_ids = [] + dev_widget = self.developer_widget + + # IDE Toolbar + def get_ide_toolbar(): + main_app.set_current("developer_view") + return (dev_widget.toolbar, None) + + step_id = guided_tour.register_widget( + widget=get_ide_toolbar, + title="IDE Toolbar", + text="Quick access to save files, execute scripts, and configure IDE settings. Use the toolbar to manage your code and execution.", + ) + step_ids.append(step_id) + + # IDE Explorer + def get_ide_explorer(): + main_app.set_current("developer_view") + return (dev_widget.explorer_dock.widget(), None) + + step_id = guided_tour.register_widget( + widget=get_ide_explorer, + title="File Explorer", + text="Browse and manage your macro files. Create new files, open existing ones, and organize your scripts.", + ) + step_ids.append(step_id) + + # IDE Editor + def get_ide_editor(): + main_app.set_current("developer_view") + return (dev_widget.monaco_dock.widget(), None) + + step_id = guided_tour.register_widget( + widget=get_ide_editor, + title="Code Editor", + text="Write and edit Python code with syntax highlighting, auto-completion, and signature help. Monaco editor provides a modern coding experience.", + ) + step_ids.append(step_id) + + # IDE Console + def get_ide_console(): + main_app.set_current("developer_view") + return (dev_widget.console_dock.widget(), None) + + step_id = guided_tour.register_widget( + widget=get_ide_console, + title="BEC Shell Console", + text="Interactive Python console with BEC integration. Execute commands, test code snippets, and interact with the BEC system in real-time.", + ) + step_ids.append(step_id) + + # IDE Plotting Area + def get_ide_plotting(): + main_app.set_current("developer_view") + return (dev_widget.plotting_ads, None) + + step_id = guided_tour.register_widget( + widget=get_ide_plotting, + title="Plotting Area", + text="View plots and visualizations generated by your scripts. Arrange multiple plots in a flexible layout.", + ) + step_ids.append(step_id) + + return ViewTourSteps(view_title="Integrated Development Environment", step_ids=step_ids) + if __name__ == "__main__": import sys diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py index 24e80688..1044d9ea 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_view.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -5,7 +5,7 @@ from qtpy.QtWidgets import QWidget from bec_widgets.applications.views.device_manager_view.device_manager_widget import ( DeviceManagerWidget, ) -from bec_widgets.applications.views.view import ViewBase +from bec_widgets.applications.views.view import ViewBase, ViewTourSteps from bec_widgets.utils.error_popups import SafeSlot @@ -34,6 +34,45 @@ class DeviceManagerView(ViewBase): """ self.device_manager_widget.on_enter() + def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None: + """Register Device Manager components with the guided tour. + + Args: + guided_tour: The GuidedTour instance to register with. + main_app: The main application instance (for accessing set_current). + + Returns: + ViewTourSteps | None: Model containing view title and step IDs. + """ + step_ids = [] + dm_widget = self.device_manager_widget + + # Register Load Current Config button + def get_load_current(): + main_app.set_current("device_manager") + return (dm_widget.button_load_current_config, None) + + step_id = guided_tour.register_widget( + widget=get_load_current, + title="Load Current Config", + text="Load the current device configuration from the BEC server. This will display all available devices and their current status.", + ) + step_ids.append(step_id) + + # Register Load Config From File button + def get_load_file(): + main_app.set_current("device_manager") + return (dm_widget.button_load_config_from_file, None) + + step_id = guided_tour.register_widget( + widget=get_load_file, + title="Load Config From File", + text="Load a device configuration from a YAML file on disk. Useful for testing or working offline.", + ) + step_ids.append(step_id) + + return ViewTourSteps(view_title="Device Manager", step_ids=step_ids) + if __name__ == "__main__": # pragma: no cover import sys diff --git a/bec_widgets/applications/views/view.py b/bec_widgets/applications/views/view.py index c1351aa6..37d15b69 100644 --- a/bec_widgets/applications/views/view.py +++ b/bec_widgets/applications/views/view.py @@ -1,6 +1,9 @@ from __future__ import annotations -from qtpy.QtCore import QEventLoop +from typing import List + +from pydantic import BaseModel +from qtpy.QtCore import QEventLoop, Qt, QTimer from qtpy.QtWidgets import ( QDialog, QDialogButtonBox, @@ -20,6 +23,54 @@ from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox im from bec_widgets.widgets.plots.waveform.waveform import Waveform +def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: + """ + Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1]. + Works for horizontal or vertical splitters and sets matching stretch factors. + """ + + def apply(): + n = splitter.count() + if n == 0: + return + w = list(weights[:n]) + [1] * max(0, n - len(weights)) + w = [max(0.0, float(x)) for x in w] + tot_w = sum(w) + if tot_w <= 0: + w = [1.0] * n + tot_w = float(n) + total_px = ( + splitter.width() + if splitter.orientation() == Qt.Orientation.Horizontal + else splitter.height() + ) + if total_px < 2: + QTimer.singleShot(0, apply) + return + sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w] + diff = total_px - sum(sizes) + if diff != 0: + idx = max(range(n), key=lambda i: w[i]) + sizes[idx] = max(1, sizes[idx] + diff) + splitter.setSizes(sizes) + for i, wi in enumerate(w): + splitter.setStretchFactor(i, max(1, int(round(wi * 100)))) + + QTimer.singleShot(0, apply) + + +class ViewTourSteps(BaseModel): + """Model representing tour steps for a view. + + Attributes: + view_title: The human-readable title of the view. + step_ids: List of registered step IDs in the order they should appear. + """ + + view_title: str + step_ids: list[str] + + class ViewBase(QWidget): """Wrapper for a content widget used inside the main app's stacked view. @@ -76,6 +127,83 @@ class ViewBase(QWidget): """ return True + def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None: + """Register this view's components with the guided tour. + + Args: + guided_tour: The GuidedTour instance to register with. + main_app: The main application instance (for accessing set_current). + + Returns: + ViewTourSteps | None: A model containing the view title and step IDs, + or None if this view has no tour steps. + + Override this method in subclasses to register view-specific components. + """ + return None + + ####### Default view has to be done with setting up splitters ######## + def set_default_view(self, horizontal_weights: list, vertical_weights: list): + """Apply initial weights to every horizontal and vertical splitter. + + Examples: + horizontal_weights = [1, 3, 2, 1] + vertical_weights = [3, 7] # top:bottom = 30:70 + """ + splitters_h = [] + splitters_v = [] + for splitter in self.findChildren(QSplitter): + if splitter.orientation() == Qt.Orientation.Horizontal: + splitters_h.append(splitter) + elif splitter.orientation() == Qt.Orientation.Vertical: + splitters_v.append(splitter) + + def apply_all(): + for s in splitters_h: + set_splitter_weights(s, horizontal_weights) + for s in splitters_v: + set_splitter_weights(s, vertical_weights) + + QTimer.singleShot(0, apply_all) + + def set_stretch(self, *, horizontal=None, vertical=None): + """Update splitter weights and re-apply to all splitters. + + Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict + for convenience: horizontal roles = {"left","center","right"}, + vertical roles = {"top","bottom"}. + """ + + def _coerce_h(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [ + float(x.get("left", 1)), + float(x.get("center", x.get("middle", 1))), + float(x.get("right", 1)), + ] + return None + + def _coerce_v(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [float(x.get("top", 1)), float(x.get("bottom", 1))] + return None + + h = _coerce_h(horizontal) + v = _coerce_v(vertical) + if h is None: + h = [1, 1, 1] + if v is None: + v = [1, 1] + self.set_default_view(h, v) + #################################################################################################### # Example views for demonstration/testing purposes diff --git a/bec_widgets/utils/guided_tour.py b/bec_widgets/utils/guided_tour.py index 4261c703..ba367a02 100644 --- a/bec_widgets/utils/guided_tour.py +++ b/bec_widgets/utils/guided_tour.py @@ -19,6 +19,7 @@ from qtpy.QtWidgets import ( QHBoxLayout, QLabel, QMainWindow, + QMenu, QMenuBar, QPushButton, QToolBar, @@ -327,11 +328,14 @@ class GuidedTour(QObject): if mb and mb not in menubars: menubars.append(mb) menubars += [mb for mb in mw.findChildren(QMenuBar) if mb not in menubars] + menubars += [mb for mb in mw.findChildren(QMenu) if mb not in menubars] + for mb in menubars: if action in mb.actions(): ar = mb.actionGeometry(action) top_left = mb.mapTo(mw, ar.topLeft()) return QRect(top_left, ar.size()) + return None def unregister_widget(self, step_id: str) -> bool: @@ -575,7 +579,7 @@ class GuidedTour(QObject): """ Skip the current step (or stop the tour) when the target cannot be visualised. """ - logger.warning("%s Skipping step %r.", reason, step_title) + logger.warning(f"{reason} Skipping step {step_title!r}.") if self._current_index < len(self._tour_steps) - 1: self._current_index += 1 self._show_current_step() @@ -663,8 +667,33 @@ class MainWindow(QMainWindow): # pragma: no cover title="Tools Menu", ) + sub_menu_action = self.tools_menu_actions["notes"].action + + def get_sub_menu_action(): + # open the tools menu + menu_button = self.tools_menu_action._button_ref() + if menu_button: + menu_button.showMenu() + + return ( + self.tools_menu_action.actions["notes"].action, + "This action allows you to add notes.", + ) + + sub_menu = self.guided_help.register_widget( + widget=get_sub_menu_action, + text="This is a sub-action within the tools menu.", + title="Add Note Action", + ) + # Create tour from registered widgets - self.tour_step_ids = [primary_step, secondary_step, toolbar_action_step, tools_menu_step] + self.tour_step_ids = [ + sub_menu, + primary_step, + secondary_step, + toolbar_action_step, + tools_menu_step, + ] widget_ids = self.tour_step_ids self.guided_help.create_tour(widget_ids) diff --git a/bec_widgets/widgets/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py index 129b24f9..a3aafa5d 100644 --- a/bec_widgets/widgets/containers/dock_area/dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -19,6 +19,7 @@ from qtpy.QtWidgets import ( import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets import BECWidget, SafeProperty, SafeSlot +from bec_widgets.applications.views.view import ViewTourSteps from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils import BECDispatcher from bec_widgets.utils.colors import apply_theme @@ -1138,6 +1139,33 @@ class BECDockArea(DockAreaWidget): set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) self._exit_snapshot_written = True + def register_tour_steps(self, guided_tour, main_app): + """Register Dock Area components with the guided tour. + + Args: + guided_tour: The GuidedTour instance to register with. + main_app: The main application instance (for accessing set_current). + + Returns: + ViewTourSteps | None: Model containing view title and step IDs. + """ + + step_ids = [] + + # Register Dock Area toolbar + def get_dock_toolbar(): + main_app.set_current("dock_area") + return (self.toolbar, None) + + step_id = guided_tour.register_widget( + widget=get_dock_toolbar, + title="Dock Area Toolbar", + text="Use this toolbar to add widgets, manage workspaces, save and load profiles, and control the layout of your workspace.", + ) + step_ids.append(step_id) + + return ViewTourSteps(view_title="Dock Area Workspace", step_ids=step_ids) + def cleanup(self): """ Cleanup the dock area. diff --git a/tests/unit_tests/test_main_app.py b/tests/unit_tests/test_main_app.py index 3d3a42f1..8b782d21 100644 --- a/tests/unit_tests/test_main_app.py +++ b/tests/unit_tests/test_main_app.py @@ -109,3 +109,77 @@ def test_on_exit_veto_prevents_switch_until_allowed(app_with_spies, qtbot): # Now the switch should have happened, and v1 received on_enter assert app.stack.currentIndex() == app._view_index["v1"] assert v1.enter_calls >= 1 + + +def test_guided_tour_is_initialized(app_with_spies): + """Test that the guided tour is initialized in the main app.""" + app, _, _, _ = app_with_spies + + # Check that guided_tour exists + assert hasattr(app, "guided_tour") + assert app.guided_tour is not None + + # Check that start_guided_tour method exists + assert hasattr(app, "start_guided_tour") + assert callable(app.start_guided_tour) + + +def test_guided_tour_has_registered_widgets(app_with_spies): + """Test that the guided tour has registered widgets.""" + app, _, _, _ = app_with_spies + + # Get registered widgets + registered = app.guided_tour.get_registered_widgets() + + # Should have at least some registered widgets + assert len(registered) > 0 + + # Check that tour steps were created + assert len(app.guided_tour._tour_steps) > 0 + + +def test_views_can_extend_guided_tour(app_with_spies): + """Test that views can register their own tour steps.""" + app, _, _, _ = app_with_spies + + # Check that device manager has register_tour_steps method + assert hasattr(app.device_manager, "register_tour_steps") + assert callable(app.device_manager.register_tour_steps) + + # Check that developer view has register_tour_steps method + assert hasattr(app.developer_view, "register_tour_steps") + assert callable(app.developer_view.register_tour_steps) + + # Verify that calling register_tour_steps returns ViewTourSteps or None + dm_tour = app.device_manager.register_tour_steps(app.guided_tour, app) + if dm_tour is not None: + assert hasattr(dm_tour, "view_title") + assert hasattr(dm_tour, "step_ids") + assert isinstance(dm_tour.step_ids, list) + + ide_tour = app.developer_view.register_tour_steps(app.guided_tour, app) + if ide_tour is not None: + assert hasattr(ide_tour, "view_title") + assert hasattr(ide_tour, "step_ids") + assert isinstance(ide_tour.step_ids, list) + + +def test_guided_tour_can_start_and_stop(app_with_spies, qtbot): + """Test that the guided tour can be started and stopped.""" + app, _, _, _ = app_with_spies + + # Start the tour + app.start_guided_tour() + qtbot.wait(100) + + # Check that tour is active + assert app.guided_tour._active + assert app.guided_tour.overlay is not None + assert app.guided_tour.overlay.isVisible() + + # Stop the tour + app.guided_tour.stop_tour() + qtbot.wait(100) + + # Check that tour is stopped + assert not app.guided_tour._active