From e1e0fbd39023048076bda76d94e757f615314def 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 | 558 ++++++++++++++++++++++++++ bec_widgets/utils/toolbars/actions.py | 72 ++++ tests/unit_tests/test_guided_tour.py | 376 +++++++++++++++++ 3 files changed, 1006 insertions(+) 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..2b89bc9e --- /dev/null +++ b/bec_widgets/utils/guided_tour.py @@ -0,0 +1,558 @@ +"""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, Optional, 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 QColor, QPainter, QPen +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QMainWindow, + QPushButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.error_popups import SafeSlot + +logger = bec_logger.logger + + +class TourStep(TypedDict): + """Type definition for a tour step.""" + + widget_ref: ( + louie.saferef.BoundMethodWeakref + | weakref.ReferenceType[QWidget | Callable[[], tuple[QWidget, str | None]]] + | Callable[[], tuple[QWidget, 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. + """ + 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): + super().__init__() + 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) -> Optional[QWidget]: + """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 | Callable[[], tuple[QWidget, str | None]], + text: str = "", + title: str = "", + ) -> str: + """ + Register a widget with help text for tours. + + Args: + widget (QWidget | Callable[[], tuple[QWidget, 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). + + Returns: + str: The unique ID for the registered widget. + """ + step_id = str(uuid4()) + + # Check if it is a bound method + if callable(widget) and not hasattr(widget, "__self__"): + # We are dealing with a plain callable + widget_ref = widget + else: + # Create weak reference for QWidget instances + widget_ref = saferef.safe_ref(widget) + + self._registered_widgets[step_id] = { + "widget_ref": widget_ref, + "text": text, + "title": title + or (widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget"), + } + logger.debug(f"Registered widget {title} with ID {step_id}") + return step_id + + 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. + """ + 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] + widget_ref = step.get("widget_ref") + + step_title = step["title"] + widget = None + step_text = step["text"] + + # Resolve weak reference + if isinstance(widget_ref, (louie.saferef.BoundMethodWeakref, weakref.ReferenceType)): + widget = widget_ref() + else: + widget = widget_ref + + # If the widget does not exist, log warning and skip to next step or stop tour + if not widget: + logger.warning( + f"Widget for step {step['title']} no longer exists (weak reference is dead)" + ) + # Skip to next step or stop tour if this was the last step + if self._current_index < len(self._tour_steps) - 1: + self._current_index += 1 + self._show_current_step() + else: + self.stop_tour() + return + + # If the user provided a callable, resolve it to get the widget and optional alt text + if callable(widget): + widget, alt_text = widget() + if alt_text: + step_text = alt_text + + if not widget.isVisible(): + logger.warning(f"Widget for step {step['title']} is not visible") + return + + # Map widget coordinates to overlay coordinates + 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 + + rect = widget.rect() + top_left = widget.mapTo(main_window, rect.topLeft()) + global_rect = QRect(top_left, rect.size()) + + # Calculate step numbers + current_step = self._current_index + 1 + total_steps = len(self._tour_steps) + + self.overlay.show_step(global_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 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 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 Help Demo") + central = QWidget() + layout = QVBoxLayout(central) + + # Create demo widgets + self.btn1 = QPushButton("Button 1") + self.btn2 = QPushButton("Button 2") + self.start_tour_btn = QPushButton("Start Guided Tour") + + layout.addWidget(QLabel("Welcome to the Guided Help Demo!")) + layout.addWidget(self.btn1) + layout.addWidget(self.btn2) + layout.addWidget(self.start_tour_btn) + layout.addStretch() + self.setCentralWidget(central) + + # Create guided help system + self.guided_help = GuidedTour(self) + + # Register widgets with help text + self.guided_help.register_widget( + widget=self.btn1, + text="This is the first button. It demonstrates how to highlight a widget and show help text next to it.", + title="First Button", + ) + + def widget_with_alt_text(): + import numpy as np + + if np.random.randint(0, 10) % 2 == 0: + return (self.btn2, None) + return (self.btn2, "This is an alternative help text for Button 2.") + + self.guided_help.register_widget( + widget=widget_with_alt_text, + text="This is the second button. Notice how the help tooltip is positioned smartly to stay visible.", + title="Second Button", + ) + + # Create tour from registered widgets + widget_ids = list(self.guided_help.get_registered_widgets().keys()) + self.guided_help.create_tour(widget_ids) + + # Connect start button + self.start_tour_btn.clicked.connect(self.guided_help.start_tour) + + +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..3ac67fc0 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -25,6 +25,7 @@ from qtpy.QtWidgets import ( ) import bec_widgets +from bec_widgets.utils.guided_tour import GuidedTour from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox @@ -587,3 +588,74 @@ 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, + ) + + 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, text, 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..bb3f7a79 --- /dev/null +++ b/tests/unit_tests/test_guided_tour.py @@ -0,0 +1,376 @@ +from unittest import mock + +import pytest +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.guided_tour import GuidedTour + + +@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) + + +@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" + + @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