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