1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-04 16:02:51 +01:00

feat: guided tour for main app

This commit is contained in:
2026-01-22 12:29:14 +01:00
committed by Christian Appel
parent f974e7233c
commit e45cab5b67
7 changed files with 507 additions and 5 deletions

View File

@@ -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
"""

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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