From 7983a4527a0bae8a341cbebcb4cf5c611e9c2e25 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Mon, 27 Oct 2025 22:04:51 +0100 Subject: [PATCH] feat(guided_tour): add guided tour --- bec_widgets/utils/guided_tour.py | 735 ++++++++++++++++++++++++++ bec_widgets/utils/toolbars/actions.py | 162 ++++-- tests/unit_tests/test_guided_tour.py | 405 ++++++++++++++ 3 files changed, 1265 insertions(+), 37 deletions(-) create mode 100644 bec_widgets/utils/guided_tour.py create mode 100644 tests/unit_tests/test_guided_tour.py diff --git a/bec_widgets/utils/guided_tour.py b/bec_widgets/utils/guided_tour.py new file mode 100644 index 00000000..4261c703 --- /dev/null +++ b/bec_widgets/utils/guided_tour.py @@ -0,0 +1,735 @@ +"""Module providing a guided help system for creating interactive GUI tours.""" + +from __future__ import annotations + +import sys +import weakref +from typing import Callable, Dict, List, TypedDict +from uuid import uuid4 + +import louie +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from louie import saferef +from qtpy.QtCore import QEvent, QObject, QRect, Qt, Signal +from qtpy.QtGui import QAction, QColor, QPainter, QPen +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QMainWindow, + QMenuBar, + QPushButton, + QToolBar, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import ExpandableMenuAction, MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar + +logger = bec_logger.logger + + +class TourStep(TypedDict): + """Type definition for a tour step.""" + + widget_ref: ( + louie.saferef.BoundMethodWeakref + | weakref.ReferenceType[ + QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]] + ] + | Callable[[], tuple[QWidget | QAction, str | None]] + | None + ) + text: str + title: str + + +class TutorialOverlay(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + # Keep mouse events enabled for the overlay but we'll handle them manually + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint) + self.current_rect = QRect() + self.message_box = self._create_message_box() + self.message_box.hide() + + def _create_message_box(self): + box = QFrame(self) + app = QApplication.instance() + bg_color = app.palette().window().color() + box.setStyleSheet( + f""" + QFrame {{ + background-color: {bg_color.name()}; + border-radius: 8px; + padding: 8px; + }} + """ + ) + layout = QVBoxLayout(box) + + # Top layout with close button (left) and step indicator (right) + top_layout = QHBoxLayout() + + # Close button on the left with material icon + self.close_btn = QPushButton() + self.close_btn.setIcon(material_icon("close")) + self.close_btn.setToolTip("Close") + self.close_btn.setMaximumSize(32, 32) + + # Step indicator on the right + self.step_label = QLabel() + self.step_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self.step_label.setStyleSheet("color: #666; font-size: 12px; font-weight: bold;") + + top_layout.addWidget(self.close_btn) + top_layout.addStretch() + top_layout.addWidget(self.step_label) + + # Main content label + self.label = QLabel() + self.label.setWordWrap(True) + + # Bottom navigation buttons + btn_layout = QHBoxLayout() + + # Back button with material icon + self.back_btn = QPushButton("Back") + self.back_btn.setIcon(material_icon("arrow_back")) + + # Next button with material icon + self.next_btn = QPushButton("Next") + self.next_btn.setIcon(material_icon("arrow_forward")) + + btn_layout.addStretch() + btn_layout.addWidget(self.back_btn) + btn_layout.addWidget(self.next_btn) + + layout.addLayout(top_layout) + layout.addWidget(self.label) + layout.addLayout(btn_layout) + return box + + def paintEvent(self, event): # pylint: disable=unused-argument + if not self.current_rect.isValid(): + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Create semi-transparent overlay color + overlay_color = QColor(0, 0, 0, 160) + # Use exclusive coordinates to avoid 1px gaps caused by QRect.bottom()/right() being inclusive. + r = self.current_rect + rect_x, rect_y, rect_w, rect_h = r.x(), r.y(), r.width(), r.height() + + # Paint overlay in 4 regions around the highlighted widget using exclusive bounds + # Top region (everything above the highlight) + if rect_y > 0: + top_rect = QRect(0, 0, self.width(), rect_y) + painter.fillRect(top_rect, overlay_color) + + # Bottom region (everything below the highlight) + bottom_y = rect_y + rect_h + if bottom_y < self.height(): + bottom_rect = QRect(0, bottom_y, self.width(), self.height() - bottom_y) + painter.fillRect(bottom_rect, overlay_color) + + # Left region (to the left of the highlight) + if rect_x > 0: + left_rect = QRect(0, rect_y, rect_x, rect_h) + painter.fillRect(left_rect, overlay_color) + + # Right region (to the right of the highlight) + right_x = rect_x + rect_w + if right_x < self.width(): + right_rect = QRect(right_x, rect_y, self.width() - right_x, rect_h) + painter.fillRect(right_rect, overlay_color) + + # Draw highlight border around the clear area. Expand slightly so border doesn't leave a hairline gap. + border_rect = QRect(rect_x - 2, rect_y - 2, rect_w + 4, rect_h + 4) + painter.setPen(QPen(QColor(76, 175, 80), 3)) # Green border + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRoundedRect(border_rect, 8, 8) + painter.end() + + def show_step( + self, rect: QRect, title: str, text: str, current_step: int = 1, total_steps: int = 1 + ): + """ + rect must already be in the overlay's coordinate space (i.e. mapped). + This method positions the message box so it does not overlap the rect. + + Args: + rect(QRect): rectangle to highlight + title(str): Title text for the step + text(str): Main content text for the step + current_step(int): Current step number + total_steps(int): Total number of steps in the tour + """ + self.current_rect = rect + + # Update step indicator in top right + self.step_label.setText(f"Step {current_step} of {total_steps}") + + # Update main content text (without step number since it's now in top right) + content_text = f"{title}
{text}" if title else text + self.label.setText(content_text) + self.message_box.adjustSize() # ensure layout applied + message_size = self.message_box.size() # actual widget size (width, height) + + spacing = 15 + + # Preferred placement: to the right, vertically centered + pos_x = rect.right() + spacing + pos_y = rect.center().y() - (message_size.height() // 2) + + # If it would go off the right edge, try left of the widget + if pos_x + message_size.width() > self.width(): + pos_x = rect.left() - message_size.width() - spacing + # vertical center is still good, but if that overlaps top/bottom we'll clamp below + + # If it goes off the left edge (no space either side), place below, centered horizontally + if pos_x < spacing: + pos_x = rect.center().x() - (message_size.width() // 2) + pos_y = rect.bottom() + spacing + + # If it goes off the bottom, try moving it above the widget + if pos_y + message_size.height() > self.height() - spacing: + # if there's room above the rect, put it there + candidate_y = rect.top() - message_size.height() - spacing + if candidate_y >= spacing: + pos_y = candidate_y + else: + # otherwise clamp to bottom with spacing + pos_y = max(spacing, self.height() - message_size.height() - spacing) + + # If it goes off the top, clamp down + pos_y = max(spacing, pos_y) + + # Make sure we don't poke the left edge + pos_x = max(spacing, min(pos_x, self.width() - message_size.width() - spacing)) + + # Apply geometry and show + self.message_box.setGeometry( + int(pos_x), int(pos_y), message_size.width(), message_size.height() + ) + self.message_box.show() + self.update() + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.Resize: + self.setGeometry(obj.rect()) + return False + + +class GuidedTour(QObject): + """ + A guided help system for creating interactive GUI tours. + + Allows developers to register widgets with help text and create guided tours. + """ + + tour_started = Signal() + tour_finished = Signal() + step_changed = Signal(int, int) # current_step, total_steps + + def __init__(self, main_window: QWidget, *, enforce_visibility: bool = True): + super().__init__() + self._visible_check: bool = enforce_visibility + self.main_window_ref = saferef.safe_ref(main_window) + self.overlay = None + self._registered_widgets: Dict[str, TourStep] = {} + self._tour_steps: List[TourStep] = [] + self._current_index = 0 + self._active = False + + @property + def main_window(self) -> QWidget | None: + """Get the main window from weak reference.""" + if self.main_window_ref and callable(self.main_window_ref): + widget = self.main_window_ref() + if isinstance(widget, QWidget): + return widget + return None + + def register_widget( + self, + *, + widget: QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]], + text: str = "", + title: str = "", + ) -> str: + """ + Register a widget with help text for tours. + + Args: + widget (QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]): The target widget or a callable that returns the widget and its help text. + text (str): The help text for the widget. This will be shown during the tour. + title (str, optional): A title for the widget (defaults to its class name or action text). + + Returns: + str: The unique ID for the registered widget. + """ + step_id = str(uuid4()) + # If it's a plain callable + if callable(widget) and not hasattr(widget, "__self__"): + widget_ref = widget + default_title = "Widget" + elif isinstance(widget, QAction): + widget_ref = weakref.ref(widget) + default_title = widget.text() or "Action" + elif hasattr(widget, "get_toolbar_button") and callable(widget.get_toolbar_button): + + def _resolve_toolbar_button(toolbar_action=widget): + button = toolbar_action.get_toolbar_button() + return (button, None) + + widget_ref = _resolve_toolbar_button + default_title = getattr(widget, "tooltip", "Toolbar Menu") + else: + widget_ref = saferef.safe_ref(widget) + default_title = widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget" + + self._registered_widgets[step_id] = { + "widget_ref": widget_ref, + "text": text, + "title": title or default_title, + } + logger.debug(f"Registered widget {title or default_title} with ID {step_id}") + return step_id + + def _action_highlight_rect(self, action: QAction) -> QRect | None: + """ + Try to find the QRect in main_window coordinates that should be highlighted for the given QAction. + Returns None if not found (e.g. not visible). + """ + mw = self.main_window + if mw is None: + return None + # Try toolbars first + for tb in mw.findChildren(QToolBar): + btn = tb.widgetForAction(action) + if btn and btn.isVisible(): + rect = btn.rect() + top_left = btn.mapTo(mw, rect.topLeft()) + return QRect(top_left, rect.size()) + # Try menu bars + menubars = [] + if hasattr(mw, "menuBar") and callable(getattr(mw, "menuBar", None)): + mb = mw.menuBar() + if mb and mb not in menubars: + menubars.append(mb) + menubars += [mb for mb in mw.findChildren(QMenuBar) 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: + """ + Unregister a previously registered widget. + + Args: + step_id (str): The unique ID of the registered widget. + + Returns: + bool: True if the widget was unregistered, False if not found. + """ + if self._active: + raise RuntimeError("Cannot unregister widget while tour is active") + if step_id in self._registered_widgets: + if self._registered_widgets[step_id] in self._tour_steps: + self._tour_steps.remove(self._registered_widgets[step_id]) + del self._registered_widgets[step_id] + return True + return False + + def create_tour(self, step_ids: List[str] | None = None) -> bool: + """ + Create a tour from registered widget IDs. + + Args: + step_ids (List[str], optional): List of registered widget IDs to include in the tour. If None, all registered widgets will be included. + + Returns: + bool: True if the tour was created successfully, False if any step IDs were not found + """ + if step_ids is None: + step_ids = list(self._registered_widgets.keys()) + + tour_steps = [] + for step_id in step_ids: + if step_id not in self._registered_widgets: + logger.error(f"Step ID {step_id} not found") + return False + tour_steps.append(self._registered_widgets[step_id]) + + self._tour_steps = tour_steps + logger.info(f"Created tour with {len(tour_steps)} steps") + return True + + @SafeSlot() + def start_tour(self): + """Start the guided tour.""" + if not self._tour_steps: + self.create_tour() + + if self._active: + logger.warning("Tour already active") + return + + main_window = self.main_window + if main_window is None: + logger.error("Main window no longer exists (weak reference is dead)") + return + + self._active = True + self._current_index = 0 + + # Create overlay + self.overlay = TutorialOverlay(main_window) + self.overlay.setGeometry(main_window.rect()) + self.overlay.show() + main_window.installEventFilter(self.overlay) + + # Connect signals + self.overlay.next_btn.clicked.connect(self.next_step) + self.overlay.back_btn.clicked.connect(self.prev_step) + self.overlay.close_btn.clicked.connect(self.stop_tour) + + main_window.installEventFilter(self) + self._show_current_step() + self.tour_started.emit() + logger.info("Started guided tour") + + @SafeSlot() + def stop_tour(self): + """Stop the current tour.""" + if not self._active: + return + + self._active = False + + main_window = self.main_window + if self.overlay and main_window: + main_window.removeEventFilter(self.overlay) + self.overlay.hide() + self.overlay.deleteLater() + self.overlay = None + + if main_window: + main_window.removeEventFilter(self) + self.tour_finished.emit() + logger.info("Stopped guided tour") + + @SafeSlot() + def next_step(self): + """Move to next step or finish tour if on last step.""" + if not self._active: + return + + if self._current_index < len(self._tour_steps) - 1: + self._current_index += 1 + self._show_current_step() + else: + # On last step, finish the tour + self.stop_tour() + + @SafeSlot() + def prev_step(self): + """Move to previous step.""" + if not self._active: + return + + if self._current_index > 0: + self._current_index -= 1 + self._show_current_step() + + def _show_current_step(self): + """Display the current step.""" + if not self._active or not self.overlay: + return + + step = self._tour_steps[self._current_index] + step_title = step["title"] + + target, step_text = self._resolve_step_target(step) + if target is None: + self._advance_past_invalid_step(step_title, reason="Step target no longer exists.") + return + + main_window = self.main_window + if main_window is None: + logger.error("Main window no longer exists (weak reference is dead)") + self.stop_tour() + return + + highlight_rect = self._get_highlight_rect(main_window, target, step_title) + if highlight_rect is None: + return + + # Calculate step numbers + current_step = self._current_index + 1 + total_steps = len(self._tour_steps) + + self.overlay.show_step(highlight_rect, step_title, step_text, current_step, total_steps) + + # Update button states + self.overlay.back_btn.setEnabled(self._current_index > 0) + + # Update next button text and state + is_last_step = self._current_index >= len(self._tour_steps) - 1 + if is_last_step: + self.overlay.next_btn.setText("Finish") + self.overlay.next_btn.setIcon(material_icon("check")) + self.overlay.next_btn.setEnabled(True) + else: + self.overlay.next_btn.setText("Next") + self.overlay.next_btn.setIcon(material_icon("arrow_forward")) + self.overlay.next_btn.setEnabled(True) + + self.step_changed.emit(self._current_index + 1, len(self._tour_steps)) + + def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | None, str]: + """ + Resolve the target widget/action for the given step. + + Args: + step(TourStep): The tour step to resolve. + + Returns: + tuple[QWidget | QAction | None, str]: The resolved target and the step text. + """ + widget_ref = step.get("widget_ref") + step_text = step.get("text", "") + + if isinstance(widget_ref, (louie.saferef.BoundMethodWeakref, weakref.ReferenceType)): + target = widget_ref() + else: + target = widget_ref + + if target is None: + return None, step_text + + if callable(target) and not isinstance(target, (QWidget, QAction)): + result = target() + if isinstance(result, tuple): + target, alt_text = result + if alt_text: + step_text = alt_text + else: + target = result + + return target, step_text + + def _get_highlight_rect( + self, main_window: QWidget, target: QWidget | QAction, step_title: str + ) -> QRect | None: + """ + Get the QRect in main_window coordinates to highlight for the given target. + + Args: + main_window(QWidget): The main window containing the target. + target(QWidget | QAction): The target widget or action to highlight. + step_title(str): The title of the current step (for logging purposes). + + Returns: + QRect | None: The rectangle to highlight, or None if not found/visible. + """ + if isinstance(target, QAction): + rect = self._action_highlight_rect(target) + if rect is None: + self._advance_past_invalid_step( + step_title, + reason=f"Could not find visible widget or menu for QAction {target.text()!r}.", + ) + return None + return rect + + if isinstance(target, QWidget): + if self._visible_check: + if not target.isVisible(): + self._advance_past_invalid_step( + step_title, reason=f"Widget {target!r} is not visible." + ) + return None + rect = target.rect() + top_left = target.mapTo(main_window, rect.topLeft()) + return QRect(top_left, rect.size()) + + self._advance_past_invalid_step( + step_title, reason=f"Unsupported step target type: {type(target)}" + ) + return None + + def _advance_past_invalid_step(self, step_title: str, *, reason: str): + """ + Skip the current step (or stop the tour) when the target cannot be visualised. + """ + logger.warning("%s Skipping step %r.", reason, step_title) + if self._current_index < len(self._tour_steps) - 1: + self._current_index += 1 + self._show_current_step() + else: + self.stop_tour() + + def get_registered_widgets(self) -> Dict[str, TourStep]: + """Get all registered widgets.""" + return self._registered_widgets.copy() + + def clear_registrations(self): + """Clear all registered widgets.""" + if self._active: + self.stop_tour() + self._registered_widgets.clear() + self._tour_steps.clear() + logger.info("Cleared all registrations") + + def set_visibility_enforcement(self, enabled: bool): + """Enable or disable visibility checks when highlighting widgets.""" + self._visible_check = enabled + + def eventFilter(self, obj, event): + """Handle window resize/move events to update step positioning.""" + if event.type() in (QEvent.Type.Move, QEvent.Type.Resize): + if self._active: + self._show_current_step() + return super().eventFilter(obj, event) + + +################################################################################ +############ # Example usage of GuidedTour system ############################## +################################################################################ + + +class MainWindow(QMainWindow): # pragma: no cover + def __init__(self): + super().__init__() + self.setWindowTitle("Guided Tour Demo") + central = QWidget() + layout = QVBoxLayout(central) + layout.setSpacing(12) + + layout.addWidget(QLabel("Welcome to the guided tour demo with toolbar support.")) + self.btn1 = QPushButton("Primary Button") + self.btn2 = QPushButton("Secondary Button") + self.status_label = QLabel("Use the controls below or the toolbar to interact.") + self.start_tour_btn = QPushButton("Start Guided Tour") + + layout.addWidget(self.btn1) + layout.addWidget(self.btn2) + layout.addWidget(self.status_label) + layout.addStretch() + layout.addWidget(self.start_tour_btn) + self.setCentralWidget(central) + + # Guided tour system + self.guided_help = GuidedTour(self) + + # Menus for demonstrating QAction support in menu bars + self._init_menu_bar() + + # Modular toolbar showcasing QAction targets + self._init_toolbar() + + # Register widgets and actions with help text + primary_step = self.guided_help.register_widget( + widget=self.btn1, + text="The primary button updates the status text when clicked.", + title="Primary Button", + ) + secondary_step = self.guided_help.register_widget( + widget=self.btn2, + text="The secondary button complements the demo layout.", + title="Secondary Button", + ) + toolbar_action_step = self.guided_help.register_widget( + widget=self.toolbar_tour_action.action, + text="Toolbar actions are supported in the guided tour. This one also starts the tour.", + title="Toolbar Tour Action", + ) + tools_menu_step = self.guided_help.register_widget( + widget=self.toolbar.components.get_action("menu_tools"), + text="Expandable toolbar menus group related actions. This button opens the tools menu.", + title="Tools Menu", + ) + + # Create tour from registered widgets + self.tour_step_ids = [primary_step, secondary_step, toolbar_action_step, tools_menu_step] + widget_ids = self.tour_step_ids + self.guided_help.create_tour(widget_ids) + + # Connect start button + self.start_tour_btn.clicked.connect(self.guided_help.start_tour) + + def _init_menu_bar(self): + menu_bar = self.menuBar() + info_menu = menu_bar.addMenu("Info") + info_menu.setObjectName("info-menu") + self.info_menu = info_menu + self.info_menu_action = info_menu.menuAction() + self.about_action = info_menu.addAction("About This Demo") + + def _init_toolbar(self): + self.toolbar = ModularToolBar(parent=self) + self.addToolBar(self.toolbar) + + self.toolbar_tour_action = MaterialIconAction( + "play_circle", tooltip="Start the guided tour", parent=self + ) + self.toolbar.components.add_safe("tour-start", self.toolbar_tour_action) + + self.toolbar_highlight_action = MaterialIconAction( + "visibility", tooltip="Highlight the primary button", parent=self + ) + self.toolbar.components.add_safe("inspect-primary", self.toolbar_highlight_action) + + demo_bundle = self.toolbar.new_bundle("demo") + demo_bundle.add_action("tour-start") + demo_bundle.add_action("inspect-primary") + + self._setup_tools_menu() + self.toolbar.show_bundles(["demo", "menu_tools"]) + self.toolbar.refresh() + + self.toolbar_tour_action.action.triggered.connect(self.guided_help.start_tour) + + def _setup_tools_menu(self): + self.tools_menu_actions: dict[str, MaterialIconAction] = { + "notes": MaterialIconAction( + icon_name="note_add", tooltip="Add a note", filled=True, parent=self + ), + "bookmark": MaterialIconAction( + icon_name="bookmark_add", tooltip="Bookmark current view", filled=True, parent=self + ), + "settings": MaterialIconAction( + icon_name="tune", tooltip="Adjust settings", filled=True, parent=self + ), + } + self.tools_menu_action = ExpandableMenuAction( + label="Tools ", actions=self.tools_menu_actions + ) + self.toolbar.components.add_safe("menu_tools", self.tools_menu_action) + bundle = ToolbarBundle("menu_tools", self.toolbar.components) + bundle.add_action("menu_tools") + self.toolbar.add_bundle(bundle) + + +if __name__ == "__main__": # pragma: no cover + app = QApplication(sys.argv) + from bec_qthemes import apply_theme + + apply_theme("dark") + w = MainWindow() + w.resize(400, 300) + w.show() + sys.exit(app.exec()) diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index dbeb937c..5c0b0955 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +import weakref from abc import ABC, abstractmethod from contextlib import contextmanager from typing import Dict, Literal @@ -10,7 +11,7 @@ from bec_lib.device import ReadoutPriority from bec_lib.logger import bec_logger from bec_qthemes._icon.material_icons import material_icon from qtpy.QtCore import QSize, Qt, QTimer -from qtpy.QtGui import QAction, QColor, QIcon +from qtpy.QtGui import QAction, QColor, QIcon # type: ignore from qtpy.QtWidgets import ( QApplication, QComboBox, @@ -52,9 +53,9 @@ def create_action_with_text(toolbar_action, toolbar: QToolBar): btn.setDefaultAction(toolbar_action.action) btn.setAutoRaise(True) if toolbar_action.text_position == "beside": - btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) else: - btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) + btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) btn.setText(toolbar_action.label_text) toolbar.addWidget(btn) @@ -65,7 +66,7 @@ class NoCheckDelegate(QStyledItemDelegate): def initStyleOption(self, option, index): super().initStyleOption(option, index) # Remove any check indicator - option.checkState = Qt.Unchecked + option.checkState = Qt.CheckState.Unchecked class LongPressToolButton(QToolButton): @@ -110,13 +111,15 @@ class ToolBarAction(ABC): checkable (bool, optional): Whether the action is checkable. Defaults to False. """ - def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False): + def __init__( + self, icon_path: str | None = None, tooltip: str | None = None, checkable: bool = False + ): self.icon_path = ( os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None ) - self.tooltip = tooltip - self.checkable = checkable - self.action = None + self.tooltip: str = tooltip or "" + self.checkable: bool = checkable + self.action: QAction @abstractmethod def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): @@ -132,6 +135,11 @@ class ToolBarAction(ABC): pass +class IconAction(ToolBarAction): + @abstractmethod + def get_icon(self) -> QIcon: ... + + class SeparatorAction(ToolBarAction): """Separator action for the toolbar.""" @@ -139,7 +147,7 @@ class SeparatorAction(ToolBarAction): toolbar.addSeparator() -class QtIconAction(ToolBarAction): +class QtIconAction(IconAction): def __init__( self, standard_icon, @@ -178,13 +186,13 @@ class QtIconAction(ToolBarAction): return self.icon -class MaterialIconAction(ToolBarAction): +class MaterialIconAction(IconAction): """ Action with a Material icon for the toolbar. Args: - icon_name (str, optional): The name of the Material icon. Defaults to None. - tooltip (str, optional): The tooltip for the action. Defaults to None. + icon_name (str): The name of the Material icon. + tooltip (str): The tooltip for the action. checkable (bool, optional): Whether the action is checkable. Defaults to False. filled (bool, optional): Whether the icon is filled. Defaults to False. color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon. @@ -196,8 +204,9 @@ class MaterialIconAction(ToolBarAction): def __init__( self, - icon_name: str = None, - tooltip: str = None, + icon_name: str, + tooltip: str, + *, checkable: bool = False, filled: bool = False, color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None, @@ -216,13 +225,13 @@ class MaterialIconAction(ToolBarAction): self.label_text = label_text self.text_position = text_position # Generate the icon using the material_icon helper - self.icon = material_icon( + self.icon: QIcon = material_icon( self.icon_name, size=(20, 20), convert_to_pixmap=False, filled=self.filled, color=self.color, - ) + ) # type: ignore if parent is None: logger.warning( "MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues." @@ -258,11 +267,11 @@ class DeviceSelectionAction(ToolBarAction): Action for selecting a device in a combobox. Args: - label (str): The label for the combobox. device_combobox (DeviceComboBox): The combobox for selecting the device. + label (str): The label for the combobox. """ - def __init__(self, label: str | None = None, device_combobox=None): + def __init__(self, /, device_combobox: DeviceComboBox, label: str | None = None): super().__init__() self.label = label self.device_combobox = device_combobox @@ -284,7 +293,7 @@ class DeviceSelectionAction(ToolBarAction): self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}") -class SwitchableToolBarAction(ToolBarAction): +class SwitchableToolBarAction(IconAction): """ A split toolbar action that combines a main action and a drop-down menu for additional actions. @@ -304,9 +313,9 @@ class SwitchableToolBarAction(ToolBarAction): def __init__( self, - actions: Dict[str, ToolBarAction], - initial_action: str = None, - tooltip: str = None, + actions: Dict[str, IconAction], + initial_action: str | None = None, + tooltip: str | None = None, checkable: bool = True, default_state_checked: bool = False, parent=None, @@ -329,11 +338,11 @@ class SwitchableToolBarAction(ToolBarAction): target (QWidget): The target widget for the action. """ self.main_button = LongPressToolButton(toolbar) - self.main_button.setPopupMode(QToolButton.MenuButtonPopup) + self.main_button.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) self.main_button.setCheckable(self.checkable) default_action = self.actions[self.current_key] self.main_button.setIcon(default_action.get_icon()) - self.main_button.setToolTip(default_action.tooltip) + self.main_button.setToolTip(default_action.tooltip or "") self.main_button.clicked.connect(self._trigger_current_action) menu = QMenu(self.main_button) for key, action_obj in self.actions.items(): @@ -431,11 +440,7 @@ class WidgetAction(ToolBarAction): """ def __init__( - self, - label: str | None = None, - widget: QWidget = None, - adjust_size: bool = True, - parent=None, + self, *, widget: QWidget, label: str | None = None, adjust_size: bool = True, parent=None ): super().__init__(icon_path=None, tooltip=label, checkable=False) self.label = label @@ -458,14 +463,14 @@ class WidgetAction(ToolBarAction): if self.label is not None: label_widget = QLabel(text=f"{self.label}", parent=target) - label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) - label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight) + label_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + label_widget.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) layout.addWidget(label_widget) if isinstance(self.widget, QComboBox) and self.adjust_size: - self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents) + self.widget.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) - size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + size_policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.widget.setSizePolicy(size_policy) self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget)) @@ -474,7 +479,7 @@ class WidgetAction(ToolBarAction): toolbar.addWidget(self.container) # Store the container as the action to allow toggling visibility. - self.action = self.container + self.action = self.container # type: ignore def cleanup(self): """ @@ -489,7 +494,7 @@ class WidgetAction(ToolBarAction): @staticmethod def calculate_minimum_width(combo_box: QComboBox) -> int: font_metrics = combo_box.fontMetrics() - max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count())) + max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count())) # type: ignore return max_width + 60 @@ -503,9 +508,11 @@ class ExpandableMenuAction(ToolBarAction): icon_path (str, optional): The path to the icon file. Defaults to None. """ - def __init__(self, label: str, actions: dict, icon_path: str = None): + def __init__(self, label: str, actions: dict[str, IconAction], icon_path: str | None = None): super().__init__(icon_path, label) self.actions = actions + self._button_ref: weakref.ReferenceType[QToolButton] | None = None + self._menu_ref: weakref.ReferenceType[QMenu] | None = None def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): button = QToolButton(toolbar) @@ -514,7 +521,7 @@ class ExpandableMenuAction(ToolBarAction): if self.icon_path: button.setIcon(QIcon(self.icon_path)) button.setText(self.tooltip) - button.setPopupMode(QToolButton.InstantPopup) + button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) button.setStyleSheet( """ QToolButton { @@ -541,6 +548,14 @@ class ExpandableMenuAction(ToolBarAction): menu.addAction(action) button.setMenu(menu) toolbar.addWidget(button) + self._button_ref = weakref.ref(button) + self._menu_ref = weakref.ref(menu) + + def get_toolbar_button(self) -> QToolButton | None: + return self._button_ref() if self._button_ref else None + + def get_menu(self) -> QMenu | None: + return self._menu_ref() if self._menu_ref else None class DeviceComboBoxAction(WidgetAction): @@ -587,3 +602,76 @@ class DeviceComboBoxAction(WidgetAction): self.combobox.close() self.combobox.deleteLater() return super().cleanup() + + +class TutorialAction(MaterialIconAction): + """ + Action for starting a guided tutorial/help tour. + + This action automatically initializes a GuidedTour instance and provides + methods to register widgets and start tours. + + Args: + main_window (QWidget): The main window widget for the guided tour overlay. + tooltip (str, optional): The tooltip for the action. Defaults to "Start Guided Tutorial". + parent (QWidget or None, optional): Parent widget for the underlying QAction. + """ + + def __init__(self, main_window: QWidget, tooltip: str = "Start Guided Tutorial", parent=None): + super().__init__( + icon_name="help", + tooltip=tooltip, + checkable=False, + filled=False, + color=None, + parent=parent, + ) + + from bec_widgets.utils.guided_tour import GuidedTour + + self.guided_help = GuidedTour(main_window) + self.main_window = main_window + + # Connect the action to start the tour + self.action.triggered.connect(self.start_tour) + + def register_widget(self, widget: QWidget, text: str, widget_name: str = "") -> str: + """ + Register a widget for the guided tour. + + Args: + widget (QWidget): The widget to highlight during the tour. + text (str): The help text to display. + widget_name (str, optional): Optional name for the widget. + + Returns: + str: Unique ID for the registered widget. + """ + return self.guided_help.register_widget(widget=widget, text=text, title=widget_name) + + def start_tour(self): + """Start the guided tour with all registered widgets.""" + registered_widgets = self.guided_help.get_registered_widgets() + if registered_widgets: + # Create tour from all registered widgets + step_ids = list(registered_widgets.keys()) + if self.guided_help.create_tour(step_ids): + self.guided_help.start_tour() + else: + logger.warning("Failed to create guided tour") + else: + logger.warning("No widgets registered for guided tour") + + def has_registered_widgets(self) -> bool: + """Check if any widgets have been registered for the tour.""" + return len(self.guided_help.get_registered_widgets()) > 0 + + def clear_registered_widgets(self): + """Clear all registered widgets.""" + self.guided_help.clear_registrations() + + def cleanup(self): + """Clean up the guided help instance.""" + if hasattr(self, "guided_help"): + self.guided_help.stop_tour() + super().cleanup() diff --git a/tests/unit_tests/test_guided_tour.py b/tests/unit_tests/test_guided_tour.py new file mode 100644 index 00000000..41d3320a --- /dev/null +++ b/tests/unit_tests/test_guided_tour.py @@ -0,0 +1,405 @@ +from unittest import mock + +import pytest +from qtpy.QtWidgets import QVBoxLayout, QWidget + +from bec_widgets.utils.guided_tour import GuidedTour +from bec_widgets.utils.toolbars.actions import ExpandableMenuAction, MaterialIconAction +from bec_widgets.utils.toolbars.toolbar import ModularToolBar + + +@pytest.fixture +def main_window(qtbot): + """Create a main window for testing.""" + window = QWidget() + window.resize(800, 600) + qtbot.addWidget(window) + return window + + +@pytest.fixture +def guided_help(main_window): + """Create a GuidedTour instance for testing.""" + return GuidedTour(main_window, enforce_visibility=False) + + +@pytest.fixture +def test_widget(main_window): + """Create a test widget.""" + widget = QWidget(main_window) + widget.resize(100, 50) + widget.show() + return widget + + +class DummyWidget(QWidget): + """A dummy widget for testing purposes.""" + + def isVisible(self) -> bool: + """Override isVisible to always return True for testing.""" + return True + + +class TestGuidedTour: + """Test the GuidedTour class core functionality.""" + + def test_initialization(self, guided_help): + """Test GuidedTour is properly initialized.""" + assert guided_help.main_window is not None + assert guided_help._registered_widgets == {} + assert guided_help._tour_steps == [] + assert guided_help._current_index == 0 + assert guided_help._active is False + + def test_register_widget(self, guided_help: GuidedTour, test_widget: QWidget): + """Test widget registration creates weak references.""" + widget_id = guided_help.register_widget( + widget=test_widget, text="Test widget", title="TestWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == "Test widget" + assert registered["title"] == "TestWidget" + # Check that widget_ref is callable (weak reference) + assert callable(registered["widget_ref"]) + # Check that we can dereference the weak reference + assert registered["widget_ref"]() is test_widget + + def test_register_widget_auto_name(self, guided_help: GuidedTour, test_widget: QWidget): + """Test widget registration with automatic naming.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + registered = guided_help._registered_widgets[widget_id] + assert registered["title"] == "QWidget" + + def test_create_tour_valid_ids(self, guided_help: GuidedTour, test_widget: QWidget): + """Test creating tour with valid widget IDs.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + result = guided_help.create_tour([widget_id]) + + assert result is True + assert len(guided_help._tour_steps) == 1 + assert guided_help._tour_steps[0]["text"] == "Test widget" + + def test_create_tour_invalid_ids(self, guided_help: GuidedTour): + """Test creating tour with invalid widget IDs.""" + result = guided_help.create_tour(["invalid_id"]) + + assert result is False + assert len(guided_help._tour_steps) == 0 + + def test_start_tour_no_steps(self, guided_help: GuidedTour, test_widget: QWidget): + """Test starting tour with no steps will add all registered widgets.""" + # Register a widget + guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + assert guided_help._active is True + assert guided_help._current_index == 0 + assert len(guided_help._tour_steps) == 1 + + def test_start_tour_success(self, guided_help: GuidedTour, test_widget: QWidget): + """Test successful tour start.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.create_tour([widget_id]) + + guided_help.start_tour() + + assert guided_help._active is True + assert guided_help._current_index == 0 + assert guided_help.overlay is not None + + def test_stop_tour(self, guided_help: GuidedTour, test_widget: QWidget): + """Test stopping a tour.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + guided_help.stop_tour() + + assert guided_help._active is False + + def test_next_step(self, guided_help: GuidedTour, test_widget: QWidget): + """Test moving to next step.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + guided_help.register_widget(widget=widget1, text="Step 1", title="Widget1") + guided_help.register_widget(widget=widget2, text="Step 2", title="Widget2") + + guided_help.start_tour() + + assert guided_help._current_index == 0 + + guided_help.next_step() + + assert guided_help._current_index == 1 + + def test_next_step_finish_tour(self, guided_help: GuidedTour, test_widget: QWidget): + """Test next step on last step finishes tour.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + guided_help.next_step() + + assert guided_help._active is False + + def test_prev_step(self, guided_help: GuidedTour, test_widget: QWidget): + """Test moving to previous step.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + guided_help.register_widget(widget=widget1, text="Step 1", title="Widget1") + guided_help.register_widget(widget=widget2, text="Step 2", title="Widget2") + + guided_help.start_tour() + guided_help.next_step() + + assert guided_help._current_index == 1 + + guided_help.prev_step() + + assert guided_help._current_index == 0 + + def test_get_registered_widgets(self, guided_help: GuidedTour, test_widget: QWidget): + """Test getting registered widgets.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + registered = guided_help.get_registered_widgets() + + assert widget_id in registered + assert registered[widget_id]["text"] == "Test widget" + + def test_clear_registrations(self, guided_help: GuidedTour, test_widget: QWidget): + """Test clearing all registrations.""" + guided_help.register_widget(widget=test_widget, text="Test widget") + + guided_help.clear_registrations() + + assert len(guided_help._registered_widgets) == 0 + assert len(guided_help._tour_steps) == 0 + + def test_weak_reference_main_window(self, main_window: QWidget): + """Test that main window is stored as weak reference.""" + guided_help = GuidedTour(main_window) + + # Should be able to get main window through weak reference + assert guided_help.main_window is not None + assert guided_help.main_window == main_window + + def test_complete_tour_flow(self, guided_help: GuidedTour, test_widget: QWidget): + """Test complete tour workflow.""" + # Create widgets + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + # Register widgets + id1 = guided_help.register_widget(widget=widget1, text="First widget", title="Widget1") + id2 = guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2") + + # Create and start tour + guided_help.start_tour() + + assert guided_help._active is True + assert guided_help._current_index == 0 + + # Move through tour + guided_help.next_step() + assert guided_help._current_index == 1 + + # Finish tour + guided_help.next_step() + assert guided_help._active is False + + def test_finish_button_on_last_step(self, guided_help: GuidedTour, test_widget: QWidget): + """Test that the Next button changes to Finish on the last step.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + guided_help.register_widget(widget=widget1, text="First widget", title="Widget1") + guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2") + guided_help.start_tour() + + overlay = guided_help.overlay + assert overlay is not None + + # First step should show "Next" + assert "Next" in overlay.next_btn.text() + + # Navigate to last step + guided_help.next_step() + + # Last step should show "Finish" + assert "Finish" in overlay.next_btn.text() + + def test_step_counter_display(self, guided_help: GuidedTour, test_widget: QWidget): + """Test that step counter is properly displayed.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + guided_help.register_widget(widget=widget1, text="First widget", title="Widget1") + guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2") + + guided_help.start_tour() + + overlay = guided_help.overlay + assert overlay is not None + assert overlay.step_label.text() == "Step 1 of 2" + + def test_register_expandable_menu_action(self, qtbot): + """Ensure toolbar menu actions can be registered directly.""" + window = QWidget() + layout = QVBoxLayout(window) + toolbar = ModularToolBar(parent=window) + layout.addWidget(toolbar) + qtbot.addWidget(window) + + tools_action = ExpandableMenuAction( + label="Tools ", + actions={ + "notes": MaterialIconAction( + icon_name="note_add", tooltip="Add note", filled=True, parent=window + ) + }, + ) + toolbar.components.add_safe("menu_tools", tools_action) + bundle = toolbar.new_bundle("menu_tools") + bundle.add_action("menu_tools") + toolbar.show_bundles(["menu_tools"]) + + guided = GuidedTour(window, enforce_visibility=False) + guided.register_widget(widget=tools_action, text="Toolbar tools menu") + guided.start_tour() + + assert guided._active is True + + @mock.patch("bec_widgets.utils.guided_tour.logger") + def test_error_handling(self, mock_logger, guided_help): + """Test error handling and logging.""" + # Test with invalid step ID + result = guided_help.create_tour(["invalid_id"]) + assert result is False + mock_logger.error.assert_called() + + def test_memory_safety_widget_deletion(self, guided_help: GuidedTour, test_widget: QWidget): + """Test memory safety when widget is deleted.""" + widget = QWidget(test_widget) + + # Register widget + widget_id = guided_help.register_widget(widget=widget, text="Test widget") + + # Verify weak reference works + registered = guided_help._registered_widgets[widget_id] + assert registered["widget_ref"]() is widget + + # Delete widget + widget.close() + widget.setParent(None) + del widget + + # The weak reference should now return None + # This tests that our weak reference implementation is working + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["widget_ref"]() is None + + def test_unregister_widget(self, guided_help: GuidedTour, test_widget: QWidget): + """Test unregistering a widget.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + # Unregister the widget + guided_help.unregister_widget(widget_id) + + assert widget_id not in guided_help._registered_widgets + + def test_unregister_nonexistent_widget(self, guided_help: GuidedTour): + """Test unregistering a widget that does not exist.""" + # Should not raise an error + assert guided_help.unregister_widget("nonexistent_id") is False + + def test_unregister_widget_removes_from_tour( + self, guided_help: GuidedTour, test_widget: QWidget + ): + """Test that unregistering a widget also removes it from the tour steps.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.create_tour([widget_id]) + + # Unregister the widget + guided_help.unregister_widget(widget_id) + + # The tour steps should no longer contain the unregistered widget + assert len(guided_help._tour_steps) == 0 + + def test_unregister_widget_during_tour_raises( + self, guided_help: GuidedTour, test_widget: QWidget + ): + """Test that unregistering a widget during an active tour raises an error.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + with pytest.raises(RuntimeError): + guided_help.unregister_widget(widget_id) + + def test_register_lambda_function(self, guided_help: GuidedTour, test_widget: QWidget): + """Test registering a lambda function as a widget.""" + widget_id = guided_help.register_widget( + widget=lambda: (test_widget, "test text"), text="Lambda widget", title="LambdaWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == "Lambda widget" + assert registered["title"] == "LambdaWidget" + # Check that widget_ref is callable (weak reference) + assert callable(registered["widget_ref"]) + # Check that we can dereference the weak reference + assert registered["widget_ref"]()[0] is test_widget + assert registered["widget_ref"]()[1] == "test text" + + def test_register_widget_local_function(self, guided_help: GuidedTour, test_widget: QWidget): + """Test registering a local function as a widget.""" + + def local_widget_function(): + return test_widget, "local text" + + widget_id = guided_help.register_widget( + widget=local_widget_function, text="Local function widget", title="LocalWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == "Local function widget" + assert registered["title"] == "LocalWidget" + # Check that widget_ref is callable (weak reference) + assert callable(registered["widget_ref"]) + # Check that we can dereference the weak reference + assert registered["widget_ref"]()[0] is test_widget + assert registered["widget_ref"]()[1] == "local text" + + def test_text_accepts_html_content(self, guided_help: GuidedTour, test_widget: QWidget, qtbot): + """Test that registered text can contain HTML content.""" + html_text = ( + "Bold Text with Italics and a link." + ) + widget_id = guided_help.register_widget( + widget=test_widget, text=html_text, title="HTMLWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == html_text + + def test_overlay_painter(self, guided_help: GuidedTour, test_widget: QWidget, qtbot): + """ + Test that the overlay painter works without errors. + While we cannot directly test the visual output, we can ensure + that calling the paintEvent does not raise exceptions. + """ + widget_id = guided_help.register_widget( + widget=test_widget, text="Test widget for overlay", title="OverlayWidget" + ) + widget = guided_help._registered_widgets[widget_id]["widget_ref"]() + with mock.patch.object(widget, "isVisible", return_value=True): + guided_help.start_tour() + guided_help.overlay.paintEvent(None) # Force paint event to render text + qtbot.wait(300) # Wait for rendering