feat(tour_guide): short task tours with welcome card, tour list, view hints and What's this, in QWidget and QML

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178HeT65Qj2RJqPm1yE4xdB
This commit is contained in:
Claude committed 2026-10-09 22:29:24 +00:00
1 parent b8983c5735
commit 78c4bf2b10
11 files changed
+2958 -1

No files matched your search

+237
View File
@@ -0,0 +1,237 @@
"""Task tours of the BEC main app for the tour guide (``BEC_TOUR_UI=qwidget|qml``).
Each tour covers one task in three to six steps. Views can add their own tours by implementing
``guide_tours(main_app) -> list[Tour]``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from bec_widgets.utils.tour_guide.model import Tour, TourStep
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.applications.main_app import BECMainApp
def _toolbar_component(app: BECMainApp, name: str):
"""Lazily look up a component of the dock area toolbar (the toolbar is rebuilt at times)."""
def resolve():
dock_area = getattr(app.dock_area, "dock_area", None)
if dock_area is None or not dock_area.toolbar.components.exists(name):
return None
component = dock_area.toolbar.components.get_action(name)
return getattr(component, "widget", None) or component
return resolve
def view_id_of(app: BECMainApp, view: object) -> str | None:
"""Id under which ``view`` was added to the app (ids derive from the sidebar labels)."""
if view is None:
return None
index = app.stack.indexOf(view)
for view_id, view_index in app._view_index.items(): # pylint: disable=protected-access
if view_index == index:
return view_id
return None
def app_tours(app: BECMainApp) -> list[Tour]:
"""The built-in tours of the main app."""
docks = view_id_of(app, app.dock_area)
devices = view_id_of(app, app.device_manager)
tours = [
Tour(
id="first_steps",
title="Find your way around",
summary="The sidebar, the status bar and where to get help.",
icon="explore",
order=0,
steps=[
TourStep(
title="Views",
text=(
"Each entry is a full view: Docks for plots and device controls, the"
" Device Manager for configurations. Your work in a view stays put"
" when you switch."
),
target=app.sidebar,
hint="Click the arrow at the top to show the names.",
),
TourStep(
title="Server messages",
text="Messages from the BEC server appear here. Hover for the full text.",
target=lambda: getattr(app, "_client_info_hover", None),
),
TourStep(
title="Scan progress",
text="Progress of the running scan, with elapsed and remaining time on hover.",
target=lambda: getattr(app, "_scan_progress_hover", None),
),
TourStep(
title="Notifications",
text="Errors and warnings are collected here, so none get lost.",
target=lambda: getattr(app, "notification_indicator", None),
hint="Click to filter by type.",
),
TourStep(
title="Light or dark",
text="Switch the theme. BEC remembers your choice.",
target=lambda: app.sidebar.components.get("dark_mode"),
),
TourStep(
title="Help is always here",
text=(
"Open the tours again from here or with F1. Shift+F1 turns on"
" What's this?, which explains any control you click."
),
target=lambda: app.sidebar.components.get("help"),
),
],
),
Tour(
id="workspace",
title="Build a workspace",
summary="Add plots and device controls, then save the layout as a profile.",
icon="dashboard_customize",
view=docks,
order=10,
steps=[
TourStep(
title="Add a plot",
text="Waveforms, images, heatmaps and more. Each one opens as a dock.",
target=_toolbar_component(app, "menu_plots"),
view=docks,
hint="Open the menu and pick Waveform.",
),
TourStep(
title="Add device controls",
text="Motor boxes, scan control and other controls go next to your plots.",
target=_toolbar_component(app, "menu_devices"),
view=docks,
),
TourStep(
title="Add utilities",
text="Queue, logs, terminals and other tools.",
target=_toolbar_component(app, "menu_utils"),
view=docks,
),
TourStep(
title="Arrange",
text=(
"Drag a dock's title bar to move it. Drop it on an edge to split, or on"
" another dock to make tabs."
),
target=lambda: getattr(
getattr(app.dock_area, "dock_area", None), "dock_manager", None
),
view=docks,
),
TourStep(
title="Save as a profile",
text=(
"Save the layout as a profile and switch between profiles here. BEC"
" reopens the last one on start."
),
target=_toolbar_component(app, "workspace_combo"),
view=docks,
),
],
),
Tour(
id="device_config",
title="Load and check a device config",
summary="Load the session's devices, review them and check they connect.",
icon="display_settings",
view=devices,
order=20,
steps=[
TourStep(
title="Start from the running session",
text="Loads the configuration the BEC server is using right now.",
target=lambda: _dm_widget(app, "button_load_current_config"),
view=devices,
hint="Click it to fill the table.",
),
TourStep(
title="Or from a file",
text="Opens a YAML configuration from disk instead.",
target=lambda: _dm_widget(app, "button_load_config_from_file"),
view=devices,
),
TourStep(
title="Review the devices",
text=(
"One row per device. The first two columns show whether its config is"
" valid and whether it connects; Readout priority decides when BEC"
" reads it during a scan."
),
target=lambda: _dm_table(app),
view=devices,
),
],
),
]
developer = getattr(app, "developer_view", None)
if developer is not None:
widget = developer.developer_widget
ide = view_id_of(app, developer)
tours.append(
Tour(
id="macros",
title="Write and run a macro",
summary="Edit a script, run it and see its plots.",
icon="code_blocks",
view=ide,
order=30,
steps=[
TourStep(
title="Your files",
text="Macros and scripts live here. Create a file or open one.",
target=widget.explorer_dock.widget,
view=ide,
),
TourStep(
title="Editor",
text="Python with completion and signature help.",
target=widget.monaco_dock.widget,
view=ide,
),
TourStep(
title="Run",
text="Save and run the file from the toolbar.",
target=widget.toolbar,
view=ide,
),
TourStep(
title="Console",
text="An IPython shell connected to BEC, for quick commands.",
target=widget.console_dock.widget,
view=ide,
),
TourStep(
title="Plots",
text="Plots your scripts create appear here.",
target=widget.plotting_ads,
view=ide,
),
],
)
)
return tours
def _dm_widget(app: BECMainApp, name: str):
widget = getattr(app.device_manager, "device_manager_widget", None)
if widget is None or getattr(widget, "_initialized", False):
return None
return getattr(widget, name, None)
def _dm_table(app: BECMainApp):
widget = getattr(app.device_manager, "device_manager_widget", None)
if widget is None or not getattr(widget, "_initialized", False):
return None
return widget.device_manager_display.device_table_view
+76 -1
View File
@@ -3,7 +3,7 @@ from bec_widgets.applications.startup_profiler import startup_profiler # isort:
from bec_qthemes import material_icon
from qtpy.QtCore import QTimer
from qtpy.QtGui import QAction # type: ignore
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
from qtpy.QtWidgets import QApplication, QHBoxLayout, QMenu, QStackedWidget, QWidget
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
from bec_widgets.applications.navigation_centre.side_bar import SideBar
@@ -22,6 +22,7 @@ from bec_widgets.utils.screen_utils import (
available_screen_geometry,
main_app_size_for_screen,
)
from bec_widgets.utils.tour_guide.guide import TourGuide, tour_ui_from_env
from bec_widgets.widgets.containers.dock_area.profile_utils import is_experimental_features_enabled
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
@@ -70,11 +71,20 @@ class BECMainApp(BECMainWindow):
self._setup_guided_tour()
startup_profiler.mark("guided tour")
# New tour guide (opt-in with BEC_TOUR_UI=qwidget|qml)
self.tour_guide: TourGuide | None = None
tour_ui = tour_ui_from_env()
if tour_ui is not None:
self._setup_tour_guide(tour_ui)
startup_profiler.mark("tour guide")
def showEvent(self, event):
super().showEvent(event)
if self._launcher_ready_notified:
return
self._launcher_ready_notified = True
if getattr(self, "tour_guide", None) is not None and self._tour_guide_first_run:
QTimer.singleShot(600, self.tour_guide.maybe_show_welcome)
QTimer.singleShot(0, lambda: notify_launcher_ready("bec-app", self))
def _add_views(self):
@@ -243,6 +253,8 @@ class BECMainApp(BECMainWindow):
self._current_view_id = vid
if hasattr(new_view, "on_enter"):
new_view.on_enter()
if getattr(self, "tour_guide", None) is not None:
self.tour_guide.notify_view_entered(vid)
def _setup_guided_tour(self):
"""
@@ -367,6 +379,67 @@ class BECMainApp(BECMainWindow):
"""
self.guided_tour.start_tour()
def _setup_tour_guide(self, ui: str, settings=None, first_run: bool = True):
"""
Set up the tour guide: short task tours, a Help item in the sidebar, F1 for the tour list,
Shift+F1 for "What's this?" and a first-run welcome card.
Args:
ui(str): Overlay technology, "qwidget" or "qml".
settings(QSettings, optional): Where tour progress is stored (tests pass their own).
first_run(bool): Offer the tours with a welcome card once the window is shown.
"""
# pylint: disable=import-outside-toplevel
from bec_widgets.applications.app_tours import app_tours
self._tour_guide_first_run = first_run
self.tour_guide = TourGuide(
self,
ui=ui,
settings=settings,
view_switcher=self.set_current,
current_view=lambda: self._current_view_id,
)
for tour in app_tours(self):
self.tour_guide.register_tour(tour)
for view_index in self._view_index.values():
view = self.stack.widget(view_index)
provider = getattr(view, "guide_tours", None)
if callable(provider):
for tour in provider(self) or []:
self.tour_guide.register_tour(tour)
help_item = self.sidebar.add_item(
icon="help",
title="Help & tours",
id="help",
mini_text="Help",
from_top=False,
toggleable=False,
)
help_item.activated.connect(self.tour_guide.open_hub)
self.tour_guide.set_help_anchor(help_item)
menu_bar = self.menuBar()
help_menu = next(
(menu for menu in menu_bar.findChildren(QMenu) if menu.title() == "Help"), None
)
if help_menu is not None:
help_menu.addSeparator()
hub_action = QAction(material_icon("school"), "Tours…", self)
hub_action.setShortcut("F1")
hub_action.triggered.connect(self.tour_guide.open_hub)
help_menu.addAction(hub_action)
whats_this = QAction(material_icon("help_center"), "What's This?", self)
whats_this.setShortcut("Shift+F1")
whats_this.triggered.connect(self.tour_guide.toggle_whats_this)
help_menu.addAction(whats_this)
# The classic tour stays available; F1 now opens the tour list.
for action in help_menu.actions():
if action.text() == "Start Guided Tour":
action.setShortcut("")
action.setText("Classic Guided Tour")
def _add_guided_tour_to_menu(self):
"""
Add a 'Guided Tour' action to the Help menu.
@@ -392,6 +465,8 @@ class BECMainApp(BECMainWindow):
help_menu.addAction(tour_action)
def cleanup(self):
if getattr(self, "tour_guide", None) is not None:
self.tour_guide.cleanup()
for view_id, idx in self._view_index.items():
view = self.stack.widget(idx)
view.close()
+77
View File
@@ -0,0 +1,77 @@
# Tour guide
Short, task-focused tours for the BEC main app, with the entry points that make people find them.
It replaces nothing: `bec_widgets/utils/guided_tour.py` (the classic tour) is unchanged and stays
in the Help menu as "Classic Guided Tour".
Turn it on with an environment variable:
```bash
BEC_TOUR_UI=qwidget bec-app # QWidget overlay (ux_kit controls)
BEC_TOUR_UI=qml bec-app # QML overlay (BecUi controls), same look
```
## What the user gets
| Entry point | Where | Behaviour |
| --- | --- | --- |
| Welcome card | bottom left, pointing at the Help item, on first start | Lists the tours; "Show me around", "Later" (asks again next start), "Don't show again". Does not block the app. |
| Help item | sidebar, above the theme toggle | Opens the tour list. |
| Tour list | F1, Help › Tours…, Help item | Every tour with its length, status (New, Step 3 of 5, Done), Start, Resume or Replay, overall progress, "What's this?". |
| What's this? | Shift+F1, Help menu, tour list | Outlines every documented control on screen; click one to read about it and jump into its tour. |
| View hint | the first time a view with a tour is opened | Small card "New here? Load and check a device config", Start or Not now. Shown once per tour. |
| Command palette | `TourGuide.palette_commands()` | "Show tours", "What's this?", one "Tour: …" or "Resume: …" per tour; also picked up from the Help menu. |
| Welcome screen | `TourGuide.welcome_items()` | Tours as cards with status, progress and a start callback. |
During a tour the highlighted control stays clickable ("Try it"), the card shows segmented
progress, Back/Next, and closing keeps the position for later. Steps whose control is not on
screen are dropped and the count is corrected. A completion card suggests the next tour.
Progress lives in `QSettings("bec", "bec_widgets")` under `tour_guide/`.
## Writing a tour
```python
from bec_widgets.utils.tour_guide import Tour, TourStep
Tour(
id="workspace",
title="Build a workspace", # a task, not a widget name
summary="Add plots and device controls, then save the layout as a profile.",
icon="dashboard_customize",
view="Docks", # offer it when this view is first opened
steps=[
TourStep(
title="Add a plot",
text="Waveforms, images, heatmaps and more. Each one opens as a dock.",
target=lambda: toolbar.components.get_action("menu_plots"),
view="Docks", # the guide switches views for you
hint="Open the menu and pick Waveform.",
),
],
)
```
* Keep tours to three to six steps; one tour per task.
* `target` is a widget, a `QAction`, a `QRect`, a toolbar action or a callable returning one. Make
callables side-effect free: "What's this?" resolves them too. Use `view` instead of switching
views inside the callable.
* `available=lambda: ...` leaves a step out entirely (e.g. experimental features).
* Built-in tours are in `bec_widgets/applications/app_tours.py`; a view can add its own by
implementing `guide_tours(main_app) -> list[Tour]`.
* `TourGuide.register_help(widget, title, text)` documents a control for "What's this?" without a
tour.
## Structure
| File | Role |
| --- | --- |
| `model.py` | `Tour`, `TourStep`, `TourProgress` (QSettings) |
| `guide.py` | `TourGuide`: modes, navigation, progress, hooks, keyboard (Esc, Enter, arrows) |
| `geometry.py` | target rectangles, card placement, click-through region; shared by both overlays |
| `overlay_qwidget.py` | QWidget overlay built from `ux_kit` |
| `overlay_qml.py`, `qml/TourOverlay.qml` | QML overlay built from `BecUi` |
The guide hands the overlay a plain state (`TourGuide.state()`), the overlay draws it and emits
`triggered(action, arg)`. Both overlays use `geometry.place_card` and `geometry.interaction_region`,
so they place cards and let clicks through identically.
+10
View File
@@ -0,0 +1,10 @@
"""Tour guide: short task tours, a tour list, a first-run welcome and "What's this?".
The guide (:class:`TourGuide`) holds the logic and progress; the overlay is either QWidget
(:mod:`overlay_qwidget`) or QML (:mod:`overlay_qml`), chosen with ``BEC_TOUR_UI``.
"""
from bec_widgets.utils.tour_guide.guide import TourGuide, tour_ui_from_env
from bec_widgets.utils.tour_guide.model import Tour, TourProgress, TourStep
__all__ = ["Tour", "TourGuide", "TourProgress", "TourStep", "tour_ui_from_env"]
+171
View File
@@ -0,0 +1,171 @@
"""Geometry shared by the QML and QWidget overlays: targets, card placement and click-through."""
from __future__ import annotations
from qtpy.QtCore import QPoint, QRect, QSize
from qtpy.QtGui import QAction, QRegion
from qtpy.QtWidgets import QAbstractButton, QMenu, QMenuBar, QToolBar, QWidget
#: Space between the spotlight and the card, and between the card and the window edge.
GAP = 14
MARGIN = 16
#: Padding of the spotlight around its target.
SPOT_PADDING = 6
def resolve_target(target: object) -> object:
"""Unwrap callables and toolbar actions until a widget, action or rectangle is left."""
for _ in range(3):
if target is None or isinstance(target, (QWidget, QAction, QRect)):
return target
if hasattr(target, "get_toolbar_button") and callable(target.get_toolbar_button):
target = target.get_toolbar_button()
continue
if hasattr(target, "action") and isinstance(getattr(target, "action"), QAction):
target = target.action
continue
if callable(target):
target = target()
if isinstance(target, tuple): # GuidedTour-style (target, text) callables
target = target[0]
continue
return None
return target
def _action_rect(action: QAction, window: QWidget) -> QRect | None:
for toolbar in window.findChildren(QToolBar):
button = toolbar.widgetForAction(action)
if button is not None and button.isVisible():
return QRect(button.mapTo(window, QPoint(0, 0)), button.size())
for button in window.findChildren(QAbstractButton):
if getattr(button, "defaultAction", lambda: None)() is action and button.isVisible():
return QRect(button.mapTo(window, QPoint(0, 0)), button.size())
bars: list[QWidget] = list(window.findChildren(QMenuBar)) + list(window.findChildren(QMenu))
for menu in bars:
if menu.isVisible() and action in menu.actions():
geometry = menu.actionGeometry(action)
return QRect(menu.mapTo(window, geometry.topLeft()), geometry.size())
return None
def target_rect(target: object, window: QWidget) -> QRect | None:
"""Rectangle of ``target`` in ``window`` coordinates, or None if it is not on screen.
Args:
target(object): Widget, action, rectangle, toolbar action or a callable returning one.
window(QWidget): The window the overlay covers.
Returns:
QRect | None: The visible rectangle, clipped to the window.
"""
target = resolve_target(target)
rect: QRect | None = None
if isinstance(target, QRect):
rect = QRect(target)
elif isinstance(target, QAction):
rect = _action_rect(target, window)
elif isinstance(target, QWidget):
if not target.isVisible() or target.window() is not window.window():
return None
rect = QRect(target.mapTo(window, QPoint(0, 0)), target.size())
if rect is None or not rect.isValid():
return None
rect = rect.intersected(window.rect())
return rect if rect.width() > 2 and rect.height() > 2 else None
def spotlight(rect: QRect, bounds: QRect, padding: int = SPOT_PADDING) -> QRect:
"""Grow ``rect`` by the spotlight padding, staying inside ``bounds``."""
return rect.adjusted(-padding, -padding, padding, padding).intersected(bounds)
def place_card(spot: QRect | None, card: QSize, bounds: QRect) -> tuple[QPoint, str]:
"""Place a card next to the spotlight without covering it.
Tries right, left, below and above the spotlight, in that order, then falls back to the
bottom-right corner. Without a spotlight the card is centred.
Args:
spot(QRect | None): Spotlight rectangle.
card(QSize): Size of the card.
bounds(QRect): Area the card must stay inside.
Returns:
tuple[QPoint, str]: Top-left of the card and the side of the spotlight it sits on
(``"right"``, ``"left"``, ``"below"``, ``"above"``, ``"center"`` or ``"corner"``). The
side tells the card where to draw its pointer.
"""
width, height = card.width(), card.height()
if spot is None or not spot.isValid():
center = bounds.center()
return QPoint(center.x() - width // 2, center.y() - height // 2), "center"
def clamp_y(y: int) -> int:
return max(bounds.top() + MARGIN, min(y, bounds.bottom() - MARGIN - height))
def clamp_x(x: int) -> int:
return max(bounds.left() + MARGIN, min(x, bounds.right() - MARGIN - width))
middle_y = spot.center().y() - height // 2
middle_x = spot.center().x() - width // 2
candidates = [
("right", spot.right() + GAP, clamp_y(middle_y)),
("left", spot.left() - GAP - width, clamp_y(middle_y)),
("below", clamp_x(middle_x), spot.bottom() + GAP),
("above", clamp_x(middle_x), spot.top() - GAP - height),
]
inner = bounds.adjusted(MARGIN, MARGIN, -MARGIN, -MARGIN)
for side, x, y in candidates:
rect = QRect(x, y, width, height)
if inner.contains(rect) and not rect.intersects(spot):
return QPoint(x, y), side
x = bounds.right() - MARGIN - width
y = bounds.bottom() - MARGIN - height
return QPoint(x, y), "corner"
def pointer_offset(spot: QRect | None, card: QRect, side: str) -> int:
"""Position of the card's pointer along the edge facing the spotlight, in card coordinates."""
if spot is None:
return 0
if side in ("right", "left"):
return max(18, min(spot.center().y() - card.top(), card.height() - 18))
if side in ("below", "above"):
return max(18, min(spot.center().x() - card.left(), card.width() - 18))
return 0
#: Modes in which the overlay takes all clicks except the spotlight.
BLOCKING_MODES = ("step", "hub", "done", "whatsthis")
def interaction_region(mode: str, bounds: QRect, spot: QRect | None, cards: list[QRect]) -> QRegion:
"""Region of the overlay that receives mouse input.
``welcome`` and ``hint`` only take clicks on their card, so the app stays usable. In a step
the spotlight is cut out, so the highlighted control can be tried while the tour runs.
Args:
mode(str): Overlay mode.
bounds(QRect): Overlay rectangle.
spot(QRect | None): Spotlight rectangle of the current step.
cards(list[QRect]): Rectangles of the cards on screen.
Returns:
QRegion: Region to pass to ``QWidget.setMask``.
"""
if mode in BLOCKING_MODES:
region = QRegion(bounds)
if mode == "step" and spot is not None:
region = region.subtracted(QRegion(spot))
for card in cards:
region = region.united(QRegion(card))
return region
region = QRegion()
for card in cards:
region = region.united(QRegion(card))
if region.isEmpty():
# an empty mask means "no mask" to Qt, which would block the whole window
region = QRegion(QRect(0, 0, 1, 1))
return region
+633
View File
@@ -0,0 +1,633 @@
"""The tour guide: short task tours, a tour list, a first-run welcome and a "What's this?" mode.
The guide owns the logic; an overlay (QWidget or QML, same look) only draws the state the guide
hands it and reports what the user clicked. See ``bec_widgets/utils/tour_guide/README.md``.
"""
from __future__ import annotations
import os
from typing import Callable
from bec_lib.logger import bec_logger
from qtpy.QtCore import QEvent, QObject, QRect, QSettings, Qt, QTimer, Signal
from qtpy.QtGui import QKeySequence, QShortcut
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.tour_guide.geometry import BLOCKING_MODES, spotlight, target_rect
from bec_widgets.utils.tour_guide.model import StepTarget, Tour, TourProgress, TourStep
logger = bec_logger.logger
#: Environment variable choosing the overlay technology: ``qwidget`` (default) or ``qml``.
UI_ENV = "BEC_TOUR_UI"
def tour_ui_from_env() -> str | None:
"""The overlay chosen with ``BEC_TOUR_UI`` (``qwidget`` or ``qml``), or None if unset."""
value = os.environ.get(UI_ENV, "").strip().lower()
return value if value in ("qwidget", "qml") else None
class TourGuide(QObject):
"""Runs tours over a window and keeps track of what the user has seen.
Entry points the guide provides:
* :meth:`maybe_show_welcome`: a first-run card offering the tours, pointing at the help button.
* :meth:`open_hub`: the tour list with progress, resume and replay (F1).
* :meth:`toggle_whats_this`: outlines every documented control; click one to read about it.
* :meth:`notify_view_entered`: offers a view's tour the first time the view is opened.
* :meth:`palette_commands` and :meth:`welcome_items`: hooks for the command palette and the
welcome screen.
Args:
window(QWidget): Window the overlay covers, usually the main window.
ui(str): ``"qwidget"`` or ``"qml"``.
settings(QSettings | None): Where progress is stored; the default user settings if None.
view_switcher(Callable[[str], object] | None): Called with a view id before a step that
lives in another view.
current_view(Callable[[], str | None] | None): Returns the id of the visible view.
"""
tour_started = Signal(str)
tour_finished = Signal(str, bool) # tour id, completed
step_changed = Signal(str, int, int) # tour id, index, count
mode_changed = Signal(str)
def __init__(
self,
window: QWidget,
*,
ui: str = "qwidget",
settings: QSettings | None = None,
view_switcher: Callable[[str], object] | None = None,
current_view: Callable[[], str | None] | None = None,
):
super().__init__(window)
self._window = window
self._ui = ui
self.progress = TourProgress(settings)
self._view_switcher = view_switcher
self._current_view = current_view
self._tours: dict[str, Tour] = {}
self._help: list[TourStep] = []
self._help_anchor: StepTarget = None
self._overlay = None
self._mode = "hidden"
self._tour: Tour | None = None
self._steps: list[TourStep] = []
self._index = 0
self._spot: QRect | None = None
self._hint_tour: str | None = None
self._selected_anchor = -1
self._anchors: list[dict] = []
self._last_finished: str | None = None
self._follow_timer = QTimer(self)
self._follow_timer.setInterval(250)
self._follow_timer.timeout.connect(self._follow_target)
self._shortcuts: list[QShortcut] = []
self._create_shortcuts()
window.installEventFilter(self)
# ------------------------------------------------------------------ registry
def register_tour(self, tour: Tour) -> Tour:
"""Add a tour, replacing one with the same id."""
self._tours[tour.id] = tour
return tour
def tours(self) -> list[Tour]:
"""Registered tours that have at least one available step, in display order."""
tours = [tour for tour in self._tours.values() if tour.available_steps()]
return sorted(tours, key=lambda tour: (tour.order, tour.title))
def tour(self, tour_id: str) -> Tour | None:
"""The tour with this id, or None."""
return self._tours.get(tour_id)
def register_help(self, target: StepTarget, title: str, text: str, view: str | None = None):
"""Document a control for "What's this?" without making it part of a tour."""
self._help.append(TourStep(title=title, text=text, target=target, view=view))
def set_help_anchor(self, target: StepTarget) -> None:
"""Control the welcome card and view hints point at, typically the Help button."""
self._help_anchor = target
@property
def mode(self) -> str:
"""``hidden``, ``welcome``, ``hint``, ``hub``, ``step``, ``done`` or ``whatsthis``."""
return self._mode
@property
def is_active(self) -> bool:
"""Whether a tour is running."""
return self._mode == "step"
@property
def current_tour(self) -> Tour | None:
"""The running tour."""
return self._tour if self._mode == "step" else None
@property
def current_index(self) -> int:
"""Index of the current step among the tour's available steps."""
return self._index
@property
def overlay(self):
"""The overlay widget, created on first use."""
if self._overlay is None:
self._overlay = self._create_overlay()
return self._overlay
# ------------------------------------------------------------------ status per tour
def tour_status(self, tour: Tour) -> dict:
"""Status of a tour for lists: ``new``, ``in_progress`` or ``done`` plus its progress."""
count = len(tour.available_steps())
position = self.progress.position(tour.id)
if position is not None and 0 < position < count:
return {
"status": "in_progress",
"position": position,
"progress": position / count,
"label": f"Step {position + 1} of {count}",
}
if self.progress.is_completed(tour.id):
return {"status": "done", "position": 0, "progress": 1.0, "label": "Done"}
return {"status": "new", "position": 0, "progress": 0.0, "label": "New"}
def suggested_tour(self, exclude: str | None = None) -> Tour | None:
"""First tour that is not finished yet, preferring one in progress."""
tours = [tour for tour in self.tours() if tour.id != exclude]
for wanted in ("in_progress", "new"):
for tour in tours:
if self.tour_status(tour)["status"] == wanted:
return tour
return None
# ------------------------------------------------------------------ entry points
@SafeSlot()
def maybe_show_welcome(self) -> bool:
"""Show the first-run welcome card unless it was turned off or something else is open."""
if self.progress.welcome_dismissed or self._mode != "hidden" or not self.tours():
return False
if all(self.tour_status(tour)["status"] == "done" for tour in self.tours()):
return False
self._set_mode("welcome")
return True
@SafeSlot()
def show_welcome(self) -> None:
"""Show the welcome card even if it was dismissed before."""
self._end_tour(save=True)
self._set_mode("welcome")
@SafeSlot()
def open_hub(self) -> None:
"""Show the list of tours."""
self._end_tour(save=True)
self._set_mode("hub")
@SafeSlot()
def toggle_whats_this(self) -> None:
"""Enter or leave "What's this?" mode."""
if self._mode == "whatsthis":
self.close()
return
self._end_tour(save=True)
self._selected_anchor = -1
self._set_mode("whatsthis")
@SafeSlot(str)
def notify_view_entered(self, view_id: str) -> None:
"""Offer the tour of a view the first time the view is opened."""
if self._mode != "hidden" or not view_id:
return
for tour in self.tours():
if tour.view != view_id or tour.id in self.progress.offered():
continue
if self.tour_status(tour)["status"] != "new":
continue
self.progress.mark_offered(tour.id)
self._hint_tour = tour.id
self._set_mode("hint")
return
def palette_commands(self) -> list[dict]:
"""Commands for the command palette, one per tour plus the tour list and "What's this?".
Each entry has ``uid``, ``title``, ``subtitle``, ``icon``, ``category``, ``keywords`` and
``callback``, the fields of ``PaletteCommand``.
"""
commands = [
{
"uid": "tour:hub",
"title": "Show tours",
"subtitle": "Learn BEC with short guided tours",
"icon": "school",
"category": "help",
"keywords": "help guide tutorial onboarding learn tour",
"callback": self.open_hub,
},
{
"uid": "tour:whatsthis",
"title": "What's this?",
"subtitle": "Click any control to learn what it does",
"icon": "help_center",
"category": "help",
"keywords": "help explain identify control",
"callback": self.toggle_whats_this,
},
]
for tour in self.tours():
status = self.tour_status(tour)
verb = "Resume" if status["status"] == "in_progress" else "Tour"
commands.append(
{
"uid": f"tour:{tour.id}",
"title": f"{verb}: {tour.title}",
"subtitle": f"{len(tour.available_steps())} steps · {status['label']}",
"icon": tour.icon,
"category": "help",
"keywords": f"help guide tutorial tour {tour.summary}",
"callback": lambda tid=tour.id: self.resume_tour(tid),
}
)
return commands
def welcome_items(self) -> list[dict]:
"""Tours as cards for a welcome screen: id, title, summary, icon, steps, status, progress
and a ``start`` callback (resumes a tour in progress)."""
items = []
for tour in self.tours():
status = self.tour_status(tour)
items.append(
{
"id": tour.id,
"title": tour.title,
"summary": tour.summary,
"icon": tour.icon,
"steps": len(tour.available_steps()),
"minutes": tour.minutes,
**status,
"start": lambda tid=tour.id: self.resume_tour(tid),
}
)
return items
# ------------------------------------------------------------------ tours
@SafeSlot(str)
def start_tour(self, tour_id: str, index: int = 0) -> bool:
"""Start a tour at ``index`` (0 by default).
Returns:
bool: False if the tour does not exist or has no available step.
"""
tour = self._tours.get(tour_id)
if tour is None:
logger.warning(f"Unknown tour {tour_id!r}")
return False
steps = tour.available_steps()
if not steps:
return False
self._end_tour(save=True)
self._tour = tour
self._steps = steps
self._index = max(0, min(index, len(steps) - 1))
self._set_mode("step", refresh=False)
self.tour_started.emit(tour.id)
self._show_step(direction=1)
return True
@SafeSlot(str)
def resume_tour(self, tour_id: str) -> bool:
"""Start a tour where the user left it, or from the start."""
tour = self._tours.get(tour_id)
if tour is None:
return False
status = self.tour_status(tour)
return self.start_tour(
tour_id, status["position"] if status["status"] == "in_progress" else 0
)
@SafeSlot()
def next_step(self) -> None:
"""Go to the next step, or finish the tour on the last one."""
if self._mode != "step":
return
if self._index >= len(self._steps) - 1:
self.finish_tour()
return
self._index += 1
self._show_step(direction=1)
@SafeSlot()
def prev_step(self) -> None:
"""Go to the previous step."""
if self._mode != "step" or self._index == 0:
return
self._index -= 1
self._show_step(direction=-1)
@SafeSlot()
def finish_tour(self) -> None:
"""Mark the running tour as done and show the completion card."""
if self._tour is None:
return
tour_id = self._tour.id
self.progress.mark_completed(tour_id)
self._tour = None
self._steps = []
self._last_finished = tour_id
self.tour_finished.emit(tour_id, True)
self._set_mode("done")
@SafeSlot()
def close(self) -> None:
"""Close whatever is shown. A running tour keeps its position for later."""
self._end_tour(save=True)
self._set_mode("hidden")
@SafeSlot()
def dismiss_welcome(self) -> None:
"""Turn the first-run welcome card off for good."""
self.progress.welcome_dismissed = True
self.close()
@SafeSlot()
def reset_progress(self) -> None:
"""Forget finished tours, resume positions and offers."""
self.progress.reset()
if self._mode in ("hub", "welcome"):
self._refresh()
def _end_tour(self, save: bool) -> None:
if self._tour is None:
return
tour = self._tour
if save and self._index > 0:
self.progress.save_position(tour.id, self._index)
self._tour = None
self._steps = []
self.tour_finished.emit(tour.id, False)
def _show_step(self, direction: int) -> None:
"""Show the current step, skipping steps whose target is not on screen."""
while 0 <= self._index < len(self._steps):
step = self._steps[self._index]
if step.view and self._view_switcher is not None:
current = self._current_view() if self._current_view else None
if current != step.view:
self._view_switcher(step.view)
QApplication.processEvents()
if step.target is None:
self._spot = None
break
rect = target_rect(step.target, self._window)
if rect is not None:
self._spot = spotlight(rect, self._window.rect())
break
logger.info(f"Tour step {step.title!r} has no visible target, skipping it")
del self._steps[self._index]
if direction < 0:
self._index -= 1
self._index = min(self._index, len(self._steps) - 1)
if not self._steps or self._index < 0:
self.close()
return
if self._tour is not None and self._index > 0:
self.progress.save_position(self._tour.id, self._index)
self.step_changed.emit(self._tour.id, self._index, len(self._steps))
self._refresh()
def _follow_target(self) -> None:
"""Keep the spotlight on its target while the layout moves (sidebar animation, resize)."""
if self._mode != "step" or not self._steps:
return
step = self._steps[self._index]
if step.target is None:
return
rect = target_rect(step.target, self._window)
spot = spotlight(rect, self._window.rect()) if rect is not None else None
if spot is not None and spot != self._spot:
self._spot = spot
self._refresh()
# ------------------------------------------------------------------ what's this
def _collect_anchors(self) -> list[dict]:
current = self._current_view() if self._current_view else None
anchors: list[dict] = []
seen: list[QRect] = []
def add(step: TourStep, tour: Tour | None):
if step.view and current and step.view != current:
return
if step.target is None:
return
try:
rect = target_rect(step.target, self._window)
except Exception: # pylint: disable=broad-except
return
if rect is None or any(rect == other for other in seen):
return
window_area = max(1, self._window.width() * self._window.height())
if rect.width() * rect.height() > 0.4 * window_area:
return # whole areas would hide the controls inside them
seen.append(rect)
anchors.append(
{
"rect": rect,
"title": step.title,
"text": step.text,
"tour_id": tour.id if tour else "",
"tour_title": tour.title if tour else "",
}
)
for tour in self.tours():
for step in tour.available_steps():
add(step, tour)
for step in self._help:
add(step, None)
return anchors
def select_anchor(self, index: int) -> None:
"""Show the explanation of the ``index``-th documented control in "What's this?"."""
if self._mode != "whatsthis":
return
self._selected_anchor = index if 0 <= index < len(self._anchors) else -1
self._refresh(collect=False)
# ------------------------------------------------------------------ overlay state
def state(self) -> dict:
"""Everything the overlay draws, as plain data. Rectangles are in window coordinates."""
mode = self._mode
state: dict = {"mode": mode, "spot": None, "anchor": None}
if mode in ("welcome", "hint"):
rect = target_rect(self._help_anchor, self._window) if self._help_anchor else None
state["anchor"] = rect
if mode in ("welcome", "hub"):
tours = self.tours()
state["tours"] = [
{
"id": tour.id,
"title": tour.title,
"summary": tour.summary,
"icon": tour.icon,
"steps": len(tour.available_steps()),
"minutes": tour.minutes,
**self.tour_status(tour),
}
for tour in tours
]
done = sum(1 for item in state["tours"] if item["status"] == "done")
state["done"] = done
state["total"] = len(tours)
if mode == "hint" and self._hint_tour:
tour = self._tours[self._hint_tour]
state["tour"] = {
"id": tour.id,
"title": tour.title,
"summary": tour.summary,
"icon": tour.icon,
"steps": len(tour.available_steps()),
"minutes": tour.minutes,
}
if mode == "step" and self._steps:
step = self._steps[self._index]
state.update(
spot=self._spot,
tourId=self._tour.id if self._tour else "",
tourTitle=self._tour.title if self._tour else "",
title=step.title,
text=step.text,
hint=step.hint,
index=self._index,
count=len(self._steps),
)
if mode == "done":
finished = self._tours.get(self._last_finished or "")
following = self.suggested_tour(exclude=self._last_finished)
state["finished"] = finished.title if finished else ""
state["next"] = (
{
"id": following.id,
"title": following.title,
"steps": len(following.available_steps()),
}
if following
else None
)
if mode == "whatsthis":
state["anchors"] = self._anchors
state["selected"] = self._selected_anchor
if 0 <= self._selected_anchor < len(self._anchors):
state["spot"] = spotlight(
self._anchors[self._selected_anchor]["rect"], self._window.rect()
)
return state
def _set_mode(self, mode: str, refresh: bool = True) -> None:
changed = mode != self._mode
self._mode = mode
for shortcut in self._shortcuts:
shortcut.setEnabled(mode in BLOCKING_MODES)
if mode == "step":
self._follow_timer.start()
else:
self._follow_timer.stop()
if refresh:
self._refresh()
if changed:
self.mode_changed.emit(mode)
def _refresh(self, collect: bool = True) -> None:
if self._mode == "whatsthis" and collect:
self._anchors = self._collect_anchors()
if self._mode == "hidden" and self._overlay is None:
return
self.overlay.apply_state(self.state())
def _create_overlay(self):
if self._ui == "qml":
# pylint: disable=import-outside-toplevel
from bec_widgets.utils.tour_guide.overlay_qml import QmlTourOverlay
overlay = QmlTourOverlay(self._window)
else:
# pylint: disable=import-outside-toplevel
from bec_widgets.utils.tour_guide.overlay_qwidget import TourOverlay
overlay = TourOverlay(self._window)
overlay.triggered.connect(self._on_overlay_action)
return overlay
@SafeSlot(str, str)
def _on_overlay_action(self, action: str, arg: str) -> None:
handlers: dict[str, Callable[[], object]] = {
"next": self.next_step,
"back": self.prev_step,
"close": self.close,
"hub": self.open_hub,
"never": self.dismiss_welcome,
"whatsthis": self.toggle_whats_this,
"reset": self.reset_progress,
"start": lambda: self.start_tour(arg),
"resume": lambda: self.resume_tour(arg),
"anchor": lambda: self.select_anchor(int(arg)),
}
handler = handlers.get(action)
if handler is None:
logger.warning(f"Unknown tour overlay action {action!r}")
return
handler()
# ------------------------------------------------------------------ keyboard and window
def _create_shortcuts(self) -> None:
bindings = [
(Qt.Key.Key_Escape, self._on_escape),
(Qt.Key.Key_Return, self._on_enter),
(Qt.Key.Key_Enter, self._on_enter),
(Qt.Key.Key_Right, self.next_step),
(Qt.Key.Key_Left, self.prev_step),
(Qt.Key.Key_Backspace, self.prev_step),
]
for key, slot in bindings:
shortcut = QShortcut(QKeySequence(key), self._window)
shortcut.setContext(Qt.ShortcutContext.WindowShortcut)
shortcut.activated.connect(slot)
shortcut.setEnabled(False)
self._shortcuts.append(shortcut)
def _on_escape(self) -> None:
if self._mode == "whatsthis" and self._selected_anchor >= 0:
self.select_anchor(-1)
return
self.close()
def _on_enter(self) -> None:
if self._mode == "step":
self.next_step()
elif self._mode == "done":
self.close()
def eventFilter(self, obj, event): # pylint: disable=invalid-name
"""Follow resizes of the window."""
if obj is self._window and event.type() in (QEvent.Type.Resize, QEvent.Type.Show):
if self._overlay is not None:
self._overlay.setGeometry(self._window.rect())
if self._mode == "step":
self._follow_target()
elif self._mode != "hidden":
self._refresh()
return super().eventFilter(obj, event)
def cleanup(self) -> None:
"""Stop timers and remove the overlay."""
self._follow_timer.stop()
self._window.removeEventFilter(self)
if self._overlay is not None:
self._overlay.cleanup()
self._overlay.deleteLater()
self._overlay = None
+154
View File
@@ -0,0 +1,154 @@
"""Tours, steps and the progress store of the tour guide."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
from qtpy.QtCore import QRect, QSettings
from qtpy.QtGui import QAction
from qtpy.QtWidgets import QWidget
#: What a step can point at: a widget, an action, a rectangle in main-window coordinates, an object
#: with ``get_toolbar_button()`` (toolbar actions) or a callable returning one of those.
StepTarget = QWidget | QAction | QRect | Callable[[], object] | object | None
@dataclass
class TourStep:
"""One step of a tour.
Args:
title(str): Short title of the step.
text(str): One or two sentences explaining what the target is for.
target(StepTarget): What to highlight. None shows the card centred, without a spotlight.
view(str | None): View the step lives in; the guide switches to it before the step.
hint(str): Optional "try it" line telling the user what to do with the target. The target
stays clickable during every step.
available(Callable[[], bool] | None): Predicate deciding whether the step is part of the
tour at all, evaluated when the tour starts (e.g. only in experimental mode).
"""
title: str
text: str
target: StepTarget = None
view: str | None = None
hint: str = ""
available: Callable[[], bool] | None = None
@dataclass
class Tour:
"""A short, task-focused tour.
Args:
id(str): Unique id, used for progress and the command palette.
title(str): Task-style title, e.g. "Build a workspace".
summary(str): One sentence saying what the user will be able to do afterwards.
steps(list[TourStep]): Three to six steps.
icon(str): Material icon name.
view(str | None): View the tour belongs to; entering it the first time offers the tour.
order(int): Sort order in the tour list.
"""
id: str
title: str
summary: str
steps: list[TourStep] = field(default_factory=list)
icon: str = "explore"
view: str | None = None
order: int = 100
def available_steps(self) -> list[TourStep]:
"""Steps whose ``available`` predicate passes."""
return [step for step in self.steps if step.available is None or step.available()]
@property
def minutes(self) -> int:
"""Rough duration, about 15 s per step, at least one minute."""
return max(1, round(len(self.available_steps()) * 15 / 60))
class TourProgress:
"""Remembers finished tours, where an unfinished tour stopped and which offers were seen.
Stored in ``QSettings`` under ``tour_guide/``, so it survives restarts and is per user.
Args:
settings(QSettings | None): Store to use; the application's default settings if None.
"""
GROUP = "tour_guide"
def __init__(self, settings: QSettings | None = None):
self._settings = settings if settings is not None else QSettings("bec", "bec_widgets")
def _key(self, name: str) -> str:
return f"{self.GROUP}/{name}"
def _list(self, name: str) -> list[str]:
value = self._settings.value(self._key(name), [])
if value in (None, ""):
return []
if isinstance(value, str):
return [value]
return [str(item) for item in value]
def _set_list(self, name: str, values: list[str]) -> None:
self._settings.setValue(self._key(name), list(dict.fromkeys(values)))
self._settings.sync()
def completed(self) -> list[str]:
"""Ids of finished tours."""
return self._list("completed")
def is_completed(self, tour_id: str) -> bool:
"""Whether the tour was finished at least once."""
return tour_id in self.completed()
def mark_completed(self, tour_id: str) -> None:
"""Remember that the tour was finished and forget its resume position."""
self._set_list("completed", [*self.completed(), tour_id])
self.clear_position(tour_id)
def position(self, tour_id: str) -> int | None:
"""Step index where the tour was left, or None."""
value = self._settings.value(self._key(f"position/{tour_id}"), None)
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None
def save_position(self, tour_id: str, index: int) -> None:
"""Remember the step index to resume from."""
self._settings.setValue(self._key(f"position/{tour_id}"), int(index))
self._settings.sync()
def clear_position(self, tour_id: str) -> None:
"""Forget the resume position."""
self._settings.remove(self._key(f"position/{tour_id}"))
self._settings.sync()
@property
def welcome_dismissed(self) -> bool:
"""Whether the first-run welcome card was turned off."""
value = self._settings.value(self._key("welcome_dismissed"), False)
return value in (True, "true", "1", 1)
@welcome_dismissed.setter
def welcome_dismissed(self, value: bool) -> None:
self._settings.setValue(self._key("welcome_dismissed"), bool(value))
self._settings.sync()
def offered(self) -> list[str]:
"""Ids of tours whose view hint was already shown."""
return self._list("offered")
def mark_offered(self, tour_id: str) -> None:
"""Remember that the view hint of the tour was shown."""
self._set_list("offered", [*self.offered(), tour_id])
def reset(self) -> None:
"""Forget everything (used by "Reset tour progress")."""
self._settings.remove(self.GROUP)
self._settings.sync()
+164
View File
@@ -0,0 +1,164 @@
"""QML overlay of the tour guide, built from the ``BecUi`` controls.
Same look and behaviour as :mod:`overlay_qwidget`: the guide hands over a state, the QML scene
draws it and reports clicks. Card placement and the click-through region use the same functions
from :mod:`geometry` as the QWidget overlay.
"""
from __future__ import annotations
from pathlib import Path
from qtpy.QtCore import Property, QObject, QRect, QSize, Qt, Signal, Slot
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils.quick import create_quick_widget, release_quick_widget
from bec_widgets.utils.tour_guide.geometry import interaction_region, place_card, pointer_offset
QML_FILE = Path(__file__).parent / "qml" / "TourOverlay.qml"
def _rect(rect: QRect | None) -> dict | None:
if rect is None or not rect.isValid():
return None
return {"x": rect.x(), "y": rect.y(), "w": rect.width(), "h": rect.height()}
def to_qml_state(state: dict) -> dict:
"""Convert a guide state to plain QML values (rectangles become ``{x, y, w, h}``)."""
converted = {}
for key, value in state.items():
if isinstance(value, QRect):
converted[key] = _rect(value)
elif key == "anchors":
converted[key] = [{**anchor, "rect": _rect(anchor["rect"])} for anchor in value]
else:
converted[key] = value
return converted
class TourOverlayBackend(QObject):
"""Bridge between the QML scene and the guide."""
stateChanged = Signal()
triggered = Signal(str, str)
cardChanged = Signal()
def __init__(self, overlay: QmlTourOverlay):
super().__init__(overlay)
self._overlay = overlay
self._state: dict = {"mode": "hidden"}
self._raw: dict = {"mode": "hidden"}
self._card = QRect()
@Property("QVariantMap", notify=stateChanged)
def state(self) -> dict: # pylint: disable=missing-function-docstring
return self._state
def set_state(self, state: dict) -> None:
"""Hand a new guide state to the scene."""
self._raw = state
self._state = to_qml_state(state)
self.stateChanged.emit()
@Slot(str, str)
def trigger(self, action: str, arg: str = "") -> None:
"""Called from QML when the user clicks something."""
self.triggered.emit(action, arg)
@Slot(str, int, int, result="QVariantMap")
def place(self, mode: str, width: int, height: int) -> dict:
"""Card position for ``mode`` at the given size, with the side and pointer offset."""
bounds = self._overlay.rect()
size = QSize(width, height)
if mode in ("welcome", "hint"):
anchor = self._raw.get("anchor")
if anchor is None:
return {
"x": bounds.right() - width - 18,
"y": bounds.bottom() - height - 40,
"side": "corner",
"pointer": 0,
}
elif mode in ("hub", "done"):
anchor = None
else:
anchor = self._raw.get("spot")
if mode == "whatsthis" and anchor is None:
return {"x": (bounds.width() - width) // 2, "y": 18, "side": "center", "pointer": 0}
position, side = place_card(anchor, size, bounds)
offset = pointer_offset(anchor, QRect(position, size), side)
return {"x": position.x(), "y": position.y(), "side": side, "pointer": offset}
# pylint: disable=invalid-name
@Slot(int, int, int, int)
def setCardRect(self, x: int, y: int, width: int, height: int) -> None:
"""QML reports where the card ended up, for the click-through mask."""
self._card = QRect(x, y, width, height)
self._overlay.update_mask()
@property
def card_rect(self) -> QRect:
"""Last card rectangle reported by QML."""
return self._card
@property
def raw_state(self) -> dict:
"""The state as the guide sent it."""
return self._raw
class QmlTourOverlay(QWidget):
"""Full-window overlay drawing the tour guide state with QML.
Signals:
triggered(str, str): Same actions as the QWidget overlay.
"""
triggered = Signal(str, str)
def __init__(self, window: QWidget):
super().__init__(window)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
self.backend = TourOverlayBackend(self)
self.backend.triggered.connect(self.triggered)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.view = create_quick_widget(
self, QML_FILE, properties={"backend": self.backend}, background="transparent"
)
self.view.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, True)
self.view.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
layout.addWidget(self.view)
self.setGeometry(window.rect())
self.hide()
def apply_state(self, state: dict) -> None:
"""Draw ``state`` (see ``TourGuide.state``)."""
mode = state.get("mode", "hidden")
if mode == "hidden":
self.backend.set_state(state)
self.hide()
return
self.setGeometry(self.parentWidget().rect())
self.backend.set_state(state)
self.show()
self.raise_()
self.update_mask()
def update_mask(self) -> None:
"""Let clicks through where the guide wants the app to stay usable."""
state = self.backend.raw_state
mode = state.get("mode", "hidden")
cards = [self.backend.card_rect] if self.backend.card_rect.isValid() else []
if mode in ("welcome", "hint"):
cards = [rect.adjusted(-12, -12, 12, 12) for rect in cards]
anchor = state.get("anchor")
if anchor is not None:
cards.append(anchor.adjusted(-8, -8, 8, 8))
self.setMask(interaction_region(mode, self.rect(), state.get("spot"), cards))
def cleanup(self) -> None:
"""Unload the scene."""
release_quick_widget(self.view)
@@ -0,0 +1,631 @@
"""QWidget overlay of the tour guide, built from the ``ux_kit`` controls.
The overlay covers the window, paints the dimming, spotlight and "What's this?" outlines, and
shows one card at a time. It only draws the state handed to :meth:`TourOverlay.apply_state` and
reports clicks through :attr:`TourOverlay.triggered`; the logic lives in ``TourGuide``.
"""
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import QEasingCurve, QPoint, QRect, QRectF, QSize, Qt, QVariantAnimation, Signal
from qtpy.QtGui import QColor, QFont, QPainter, QPainterPath, QPen, QPolygon
from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils.quick.tokens import ThemeTokens
from bec_widgets.utils.tour_guide.geometry import interaction_region, place_card, pointer_offset
from bec_widgets.utils.ux_kit import (
Divider,
IconButton,
LinearProgress,
StatusPill,
TextButton,
_ThemeFollower,
)
STATUS_TONES = {"new": "info", "in_progress": "warning", "done": "success"}
CARD_WIDTHS = {"step": 340, "welcome": 380, "hint": 330, "hub": 560, "done": 380, "whatsthis": 320}
def _label(text: str = "", size: int = 13, weight: QFont.Weight | None = None, wrap=True) -> QLabel:
label = QLabel(text)
font = QFont(label.font())
font.setPixelSize(size)
if weight is not None:
font.setWeight(weight)
label.setFont(font)
label.setWordWrap(wrap)
label.setTextFormat(Qt.TextFormat.PlainText)
return label
def _icon_tile(name: str, tokens: ThemeTokens, size: int = 36, tone: str = "primary") -> QLabel:
tile = QLabel()
tile.setFixedSize(size, size)
tile.setAlignment(Qt.AlignmentFlag.AlignCenter)
tile.setPixmap(
material_icon(
name, size=(size - 14, size - 14), color=tokens.tone_text(tone), convert_to_pixmap=True
)
)
tile.setStyleSheet(
f"background: {tokens.tone_tint(tone).name(QColor.NameFormat.HexArgb)};"
f" border-radius: {size // 4}px;"
)
return tile
class StepDots(QWidget):
"""Segmented progress: one bar per step, done steps filled."""
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._index = 0
self._count = 1
self.setFixedHeight(6)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
def set_progress(self, index: int, count: int) -> None:
"""Highlight steps up to ``index`` of ``count``."""
self._index, self._count = index, max(1, count)
self.update()
def paintEvent(self, _event): # pylint: disable=invalid-name
"""Paint one rounded bar per step."""
tokens = ThemeTokens.current()
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
gap = 4
width = (self.width() - gap * (self._count - 1)) / self._count
for i in range(self._count):
color = tokens.primary if i <= self._index else tokens.track
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawRoundedRect(QRectF(i * (width + gap), 0, width, 6), 3, 3)
painter.end()
class OverlayCard(_ThemeFollower, QFrame):
"""Surface of every overlay card; rebuilt from the state on each update."""
triggered = Signal(str, str)
def __init__(self, parent: QWidget, mode: str):
super().__init__(parent)
self.mode = mode
self.setObjectName("tourCard")
self.setFixedWidth(CARD_WIDTHS[mode])
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(18, 16, 18, 16)
self._layout.setSpacing(10)
self._state: dict = {}
self.refresh_theme(ThemeTokens.current())
self._follow_theme()
def refresh_theme(self, tokens: ThemeTokens) -> None:
"""Apply the theme tokens."""
self.setStyleSheet(
f"#tourCard {{ background: {tokens.card.name()}; border: 1px solid"
f" {tokens.border.name()}; border-radius: {tokens.metrics['radiusLarge'] + 2}px; }}"
f"#tourCard QLabel {{ color: {tokens.fg.name()}; background: transparent; }}"
f'#tourCard QLabel[muted="true"] {{ color: {tokens.fg_muted.name()}; }}'
f'#tourCard QLabel[caption="true"] {{ color: {tokens.primary.name()}; }}'
)
if self._state:
self.set_state(self._state)
def _emit(self, action: str, arg: str = "") -> None:
self.triggered.emit(action, arg)
def _clear(self) -> None:
def clear(layout):
while layout.count():
item = layout.takeAt(0)
if item.widget() is not None:
item.widget().hide()
item.widget().deleteLater()
elif item.layout() is not None:
clear(item.layout())
clear(self._layout)
def _button( # pylint: disable=too-many-arguments,too-many-positional-arguments
self, text, variant, action, arg="", icon="", compact=False
) -> TextButton:
button = TextButton(text, variant, icon, compact=compact)
button.clicked.connect(lambda: self._emit(action, arg))
return button
def _muted(self, text: str, size: int = 12) -> QLabel:
label = _label(text, size)
label.setProperty("muted", True)
return label
def _header(self, caption: str, close_tip: str = "Close") -> QHBoxLayout:
row = QHBoxLayout()
row.setSpacing(6)
label = _label(caption.upper(), 11, QFont.Weight.Bold, wrap=False)
label.setProperty("caption", True)
row.addWidget(label, 1)
close = IconButton("close", close_tip, compact=True)
close.clicked.connect(lambda: self._emit("close"))
row.addWidget(close)
return row
def set_state(self, state: dict) -> None:
"""Rebuild the card for ``state``."""
self._state = state
self._clear()
tokens = ThemeTokens.current()
getattr(self, f"_build_{self.mode}")(state, tokens)
self.style().unpolish(self)
self.style().polish(self)
def preferred_size(self) -> QSize:
"""Size of the card at its fixed width."""
width = self.width()
layout = self.layout()
height = layout.totalHeightForWidth(width) if layout.hasHeightForWidth() else 0
return QSize(width, max(height, layout.totalSizeHint().height()))
# -- cards ---------------------------------------------------------------
def _build_step(self, state: dict, tokens: ThemeTokens) -> None:
self._layout.addLayout(
self._header(state.get("tourTitle", ""), "Close tour (resume later)")
)
self._layout.addWidget(_label(state.get("title", ""), 16, QFont.Weight.DemiBold))
self._layout.addWidget(_label(state.get("text", ""), 13))
if state.get("hint"):
hint = QFrame()
hint.setStyleSheet(
f"background: {tokens.tone_tint('primary').name(QColor.NameFormat.HexArgb)};"
f" border-radius: {tokens.metrics['radiusSmall']}px;"
)
row = QHBoxLayout(hint)
row.setContentsMargins(10, 8, 10, 8)
icon = QLabel()
icon.setPixmap(
material_icon(
"touch_app", size=(18, 18), color=tokens.primary, convert_to_pixmap=True
)
)
row.addWidget(icon, 0, Qt.AlignmentFlag.AlignTop)
text = _label(f"Try it: {state['hint']}", 12)
row.addWidget(text, 1)
self._layout.addWidget(hint)
index, count = state.get("index", 0), state.get("count", 1)
dots = StepDots()
dots.set_progress(index, count)
self._layout.addSpacing(2)
self._layout.addWidget(dots)
footer = QHBoxLayout()
footer.setSpacing(8)
footer.addWidget(self._muted(f"{index + 1} of {count}"))
footer.addStretch(1)
back = self._button("Back", "ghost", "back", compact=True)
back.setEnabled(index > 0)
footer.addWidget(back)
last = index >= count - 1
footer.addWidget(
self._button("Finish" if last else "Next", "primary", "next", compact=True)
)
self._layout.addLayout(footer)
def _tour_row(self, tour: dict, tokens: ThemeTokens, detailed: bool) -> QWidget:
row = QFrame()
row.setObjectName("tourRow")
row.setStyleSheet(
f"#tourRow {{ background: {tokens.sunken.name()}; border-radius:"
f" {tokens.metrics['radiusSmall'] + 2}px; }}"
)
layout = QHBoxLayout(row)
layout.setContentsMargins(10, 9, 10, 9)
layout.setSpacing(12)
tone = STATUS_TONES[tour["status"]] if tour["status"] == "done" else "primary"
layout.addWidget(_icon_tile(tour["icon"], tokens, 34, tone), 0, Qt.AlignmentFlag.AlignTop)
text = QVBoxLayout()
text.setSpacing(2)
text.addWidget(_label(tour["title"], 13, QFont.Weight.DemiBold))
if detailed:
text.addWidget(self._muted(tour["summary"], 12))
meta = QHBoxLayout()
meta.setSpacing(8)
meta.addWidget(self._muted(f"{tour['steps']} steps · {tour['minutes']} min", 11))
if detailed:
meta.addWidget(
StatusPill(text=tour["label"], tone=STATUS_TONES[tour["status"]], outlined=True)
)
meta.addStretch(1)
text.addLayout(meta)
if detailed and tour["status"] == "in_progress":
progress_bar = LinearProgress(tone="warning", thickness=4)
progress_bar.set_value(tour["progress"], animate=False)
text.addWidget(progress_bar)
layout.addLayout(text, 1)
verb = {"new": "Start", "in_progress": "Resume", "done": "Replay"}[tour["status"]]
action = "start" if tour["status"] == "done" else "resume"
variant = "primary" if tour["status"] == "in_progress" else "neutral"
layout.addWidget(
self._button(verb, variant, action, tour["id"], compact=True),
0,
Qt.AlignmentFlag.AlignVCenter,
)
return row
def _build_welcome(self, state: dict, tokens: ThemeTokens) -> None:
self._layout.addLayout(self._header("Welcome to BEC", "Close (show again next time)"))
self._layout.addWidget(
_label("New here? Let us show you around.", 16, QFont.Weight.DemiBold)
)
self._layout.addWidget(
self._muted(
"Short tours of a minute or two, one task each. Pick one now, or find them later"
" under Help (F1).",
12,
)
)
for tour in state.get("tours", [])[:4]:
self._layout.addWidget(self._tour_row(tour, tokens, detailed=False))
footer = QHBoxLayout()
footer.addWidget(self._button("Don't show again", "ghost", "never", compact=True))
footer.addStretch(1)
footer.addWidget(self._button("Later", "neutral", "close", compact=True))
first = next((t for t in state.get("tours", []) if t["status"] != "done"), None)
if first:
footer.addWidget(
self._button("Show me around", "primary", "resume", first["id"], compact=True)
)
self._layout.addLayout(footer)
def _build_hint(self, state: dict, tokens: ThemeTokens) -> None:
tour = state.get("tour") or {}
row = QHBoxLayout()
row.setSpacing(12)
row.addWidget(
_icon_tile(tour.get("icon", "explore"), tokens, 36), 0, Qt.AlignmentFlag.AlignTop
)
text = QVBoxLayout()
text.setSpacing(2)
text.addWidget(_label(f"New here? {tour.get('title', '')}", 13, QFont.Weight.DemiBold))
text.addWidget(
self._muted(
f"A {tour.get('steps', 0)}-step tour of this view,"
f" about {tour.get('minutes', 1)} min.",
12,
)
)
row.addLayout(text, 1)
close = IconButton("close", "Not now", compact=True)
close.clicked.connect(lambda: self._emit("close"))
row.addWidget(close, 0, Qt.AlignmentFlag.AlignTop)
self._layout.addLayout(row)
footer = QHBoxLayout()
footer.addStretch(1)
footer.addWidget(self._button("Not now", "ghost", "close", compact=True))
footer.addWidget(
self._button("Start tour", "primary", "start", tour.get("id", ""), compact=True)
)
self._layout.addLayout(footer)
def _build_hub(self, state: dict, tokens: ThemeTokens) -> None:
head = QHBoxLayout()
head.setSpacing(12)
head.addWidget(_icon_tile("school", tokens, 40), 0, Qt.AlignmentFlag.AlignTop)
titles = QVBoxLayout()
titles.setSpacing(2)
titles.addWidget(_label("Tours", 20, QFont.Weight.DemiBold))
titles.addWidget(
self._muted("Short guided tours, one task each. Your progress is saved.", 12)
)
head.addLayout(titles, 1)
close = IconButton("close", "Close", compact=True)
close.clicked.connect(lambda: self._emit("close"))
head.addWidget(close, 0, Qt.AlignmentFlag.AlignTop)
self._layout.addLayout(head)
done, total = state.get("done", 0), max(1, state.get("total", 1))
progress = QHBoxLayout()
progress_bar = LinearProgress(tone="success", thickness=6)
progress_bar.set_value(done / total, animate=False)
progress.addWidget(progress_bar, 1)
progress.addWidget(self._muted(f"{done} of {state.get('total', 0)} done", 12))
self._layout.addLayout(progress)
for tour in state.get("tours", []):
self._layout.addWidget(self._tour_row(tour, tokens, detailed=True))
self._layout.addWidget(Divider())
footer = QHBoxLayout()
footer.addWidget(
self._button(
"What's this? Shift+F1", "neutral", "whatsthis", icon="help_center", compact=True
)
)
footer.addStretch(1)
footer.addWidget(self._button("Reset progress", "ghost", "reset", compact=True))
self._layout.addLayout(footer)
def _build_done(self, state: dict, tokens: ThemeTokens) -> None:
head = QHBoxLayout()
head.setSpacing(12)
head.addWidget(
_icon_tile("check_circle", tokens, 40, "success"), 0, Qt.AlignmentFlag.AlignTop
)
titles = QVBoxLayout()
titles.setSpacing(2)
titles.addWidget(_label("Tour complete", 16, QFont.Weight.DemiBold))
titles.addWidget(self._muted(f"You finished “{state.get('finished', '')}”.", 12))
head.addLayout(titles, 1)
self._layout.addLayout(head)
following = state.get("next")
footer = QHBoxLayout()
if following:
self._layout.addWidget(Divider())
self._layout.addWidget(self._muted("UP NEXT", 11))
self._layout.addWidget(
_label(
f"{following['title']} · {following['steps']} steps", 13, QFont.Weight.Medium
)
)
footer.addWidget(self._button("All tours", "ghost", "hub", compact=True))
footer.addStretch(1)
footer.addWidget(self._button("Close", "neutral", "close", compact=True))
if following:
footer.addWidget(
self._button("Start next", "primary", "resume", following["id"], compact=True)
)
self._layout.addLayout(footer)
def _build_whatsthis(self, state: dict, tokens: ThemeTokens) -> None:
anchors, selected = state.get("anchors", []), state.get("selected", -1)
if not 0 <= selected < len(anchors):
row = QHBoxLayout()
row.setSpacing(10)
icon = QLabel()
icon.setPixmap(
material_icon(
"help_center", size=(20, 20), color=tokens.primary, convert_to_pixmap=True
)
)
row.addWidget(icon)
row.addWidget(
_label(f"What's this? Click any outlined control ({len(anchors)} on screen).", 13),
1,
)
row.addWidget(self._button("Done", "neutral", "close", compact=True))
self._layout.addLayout(row)
return
anchor = anchors[selected]
self._layout.addLayout(self._header("What's this?", "Leave What's this"))
self._layout.addWidget(_label(anchor["title"], 15, QFont.Weight.DemiBold))
self._layout.addWidget(_label(anchor["text"], 13))
if anchor.get("tour_id"):
footer = QHBoxLayout()
footer.addWidget(self._muted(f"Part of “{anchor['tour_title']}”", 12), 1)
footer.addWidget(
self._button("Take the tour", "primary", "resume", anchor["tour_id"], compact=True)
)
self._layout.addLayout(footer)
class TourOverlay(QWidget):
"""Full-window overlay drawing the tour guide state with QWidgets.
Signals:
triggered(str, str): An action (``next``, ``back``, ``close``, ``start``, ``resume``,
``hub``, ``never``, ``whatsthis``, ``reset`` or ``anchor``) and its argument.
"""
triggered = Signal(str, str)
def __init__(self, window: QWidget):
super().__init__(window)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
self.setObjectName("tourOverlay")
self._state: dict = {"mode": "hidden"}
self._spot = QRect()
self._side = "center"
self._cards = {mode: OverlayCard(self, mode) for mode in CARD_WIDTHS}
for card in self._cards.values():
card.triggered.connect(self.triggered)
card.hide()
self._spot_animation = QVariantAnimation(self)
self._spot_animation.setDuration(220)
self._spot_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
self._spot_animation.valueChanged.connect(self._on_spot_animated)
self.setGeometry(window.rect())
self.hide()
@property
def card(self) -> OverlayCard | None:
"""The visible card."""
card = self._cards.get(self._state.get("mode", "hidden"))
return card if card is not None and card.isVisible() else None
def apply_state(self, state: dict) -> None:
"""Draw ``state`` (see ``TourGuide.state``)."""
mode = state.get("mode", "hidden")
previous_mode = self._state.get("mode")
self._state = state
self._spot_animation.stop()
if mode == "hidden":
for card in self._cards.values():
card.hide()
self.hide()
return
self.setGeometry(self.parentWidget().rect())
for name, card in self._cards.items():
if name != mode:
card.hide()
card = self._cards[mode]
# rebuild hidden: widgets added to a visible parent are only shown later, so they would be
# missing from the size hint
card.hide()
card.set_state(state)
spot = state.get("spot") or QRect()
if mode == "step" and previous_mode == "step" and self._spot.isValid() and spot.isValid():
self._spot_animation.setStartValue(QRect(self._spot))
self._spot_animation.setEndValue(QRect(spot))
self._spot_animation.start()
else:
self._spot = QRect(spot)
self._layout_card(spot if spot.isValid() else None)
card.show()
self.show()
self.raise_()
self._update_mask()
self.update()
def _anchor_for_card(self, spot: QRect | None) -> QRect | None:
mode = self._state.get("mode")
if mode in ("welcome", "hint"):
return self._state.get("anchor")
if mode in ("hub", "done"):
return None
if mode == "whatsthis" and spot is None:
return None
return spot
def _layout_card(self, spot: QRect | None) -> None:
mode = self._state.get("mode")
card = self._cards[mode]
size = card.preferred_size()
bounds = self.rect()
if mode == "whatsthis" and spot is None:
position, side = QPoint((bounds.width() - size.width()) // 2, 18), "center"
else:
anchor = self._anchor_for_card(spot)
position, side = place_card(anchor, size, bounds)
if mode in ("welcome", "hint") and anchor is None:
position, side = (
QPoint(
bounds.right() - size.width() - 18, bounds.bottom() - size.height() - 40
),
"corner",
)
self._side = side
card.setGeometry(QRect(position, size))
def _on_spot_animated(self, value) -> None:
self._spot = QRect(value)
self._update_mask()
self.update()
def _update_mask(self) -> None:
mode = self._state.get("mode", "hidden")
card = self._cards.get(mode)
cards = [card.geometry()] if card is not None else []
anchor = self._state.get("anchor")
if mode in ("welcome", "hint"):
# leave room for the pointer and the ring around the help button
cards = [rect.adjusted(-12, -12, 12, 12) for rect in cards]
if anchor is not None:
cards.append(anchor.adjusted(-8, -8, 8, 8))
spot = self._spot if self._spot.isValid() else None
self.setMask(interaction_region(mode, self.rect(), spot, cards))
# -- painting --------------------------------------------------------------
def paintEvent(self, _event): # pylint: disable=invalid-name
"""Paint the dimming, the spotlight ring, the What's this outlines and the pointer."""
mode = self._state.get("mode", "hidden")
tokens = ThemeTokens.current()
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
dim = {"step": 0.55, "hub": 0.5, "done": 0.5, "whatsthis": 0.28}.get(mode)
spot = self._spot if self._spot.isValid() else None
if dim is not None:
path = QPainterPath()
path.addRect(QRectF(self.rect()))
if spot is not None and mode in ("step", "whatsthis"):
hole = QPainterPath()
hole.addRoundedRect(QRectF(spot), 10, 10)
path = path.subtracted(hole)
painter.fillPath(path, QColor(0, 0, 0, int(dim * 255)))
if mode == "whatsthis":
pen = QPen(tokens.primary, 1.5, Qt.PenStyle.DashLine)
for index, anchor in enumerate(self._state.get("anchors", [])):
if index == self._state.get("selected", -1):
continue
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRoundedRect(QRectF(anchor["rect"]).adjusted(1, 1, -1, -1), 6, 6)
self._paint_marker(painter, anchor["rect"], tokens)
if spot is not None and mode in ("step", "whatsthis"):
self._paint_ring(painter, QRectF(spot), tokens)
anchor = self._state.get("anchor")
if mode in ("welcome", "hint") and anchor is not None:
self._paint_ring(painter, QRectF(anchor.adjusted(-4, -4, 4, 4)), tokens)
card = self.card
if card is not None and self._side in ("right", "left", "below", "above"):
self._paint_pointer(painter, card.geometry(), tokens)
painter.end()
def _paint_ring(self, painter: QPainter, rect: QRectF, tokens: ThemeTokens) -> None:
glow = QColor(tokens.primary)
for width, alpha in ((10, 40), (6, 70)):
glow.setAlpha(alpha)
painter.setPen(QPen(glow, width))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRoundedRect(rect, 10, 10)
painter.setPen(QPen(tokens.primary, 2))
painter.drawRoundedRect(rect, 10, 10)
def _paint_marker(self, painter: QPainter, rect: QRect, tokens: ThemeTokens) -> None:
center = QPoint(rect.right() - 2, rect.top() + 2)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(tokens.primary)
painter.drawEllipse(center, 7, 7)
painter.setPen(tokens.on_primary)
font = QFont(self.font())
font.setPixelSize(10)
font.setBold(True)
painter.setFont(font)
painter.drawText(
QRect(center.x() - 7, center.y() - 7, 14, 14), Qt.AlignmentFlag.AlignCenter, "?"
)
def _paint_pointer(self, painter: QPainter, card: QRect, tokens: ThemeTokens) -> None:
mode = self._state.get("mode")
target = self._state.get("anchor") if mode in ("welcome", "hint") else self._spot
offset = pointer_offset(target, card, self._side)
size = 8
if self._side == "right":
tip, base = QPoint(card.left() - size, card.top() + offset), card.left() + 1
points = [tip, QPoint(base, tip.y() - size), QPoint(base, tip.y() + size)]
elif self._side == "left":
tip, base = QPoint(card.right() + size + 1, card.top() + offset), card.right()
points = [tip, QPoint(base, tip.y() - size), QPoint(base, tip.y() + size)]
elif self._side == "below":
tip, base = QPoint(card.left() + offset, card.top() - size), card.top() + 1
points = [tip, QPoint(tip.x() - size, base), QPoint(tip.x() + size, base)]
else:
tip, base = QPoint(card.left() + offset, card.bottom() + size + 1), card.bottom()
points = [tip, QPoint(tip.x() - size, base), QPoint(tip.x() + size, base)]
painter.setPen(QPen(tokens.border, 1))
painter.setBrush(tokens.card)
painter.drawPolygon(QPolygon(points))
# -- input -----------------------------------------------------------------
def mousePressEvent(self, event): # pylint: disable=invalid-name
"""Route clicks outside the card: pick an anchor, open the tour list or close."""
mode = self._state.get("mode")
pos = event.position().toPoint()
if mode == "whatsthis":
for index, anchor in enumerate(self._state.get("anchors", [])):
if anchor["rect"].contains(pos):
self.triggered.emit("anchor", str(index))
return
self.triggered.emit("anchor", "-1")
return
if mode in ("welcome", "hint"):
anchor = self._state.get("anchor")
if anchor is not None and anchor.adjusted(-6, -6, 6, 6).contains(pos):
self.triggered.emit("hub", "")
return
if mode in ("hub", "done"):
card = self.card
if card is None or not card.geometry().contains(pos):
self.triggered.emit("close", "")
event.accept()
def cleanup(self) -> None:
"""Stop animations."""
self._spot_animation.stop()
@@ -0,0 +1,489 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Shapes
import BecUi
// Tour guide overlay: dimming with a spotlight, "What's this?" outlines and one card per mode.
// Draws backend.state (see TourGuide.state) and reports clicks with backend.trigger(action, arg).
Item {
id: root
required property QtObject backend
readonly property var st: backend.state
readonly property string mode: st.mode || "hidden"
readonly property bool hasSpot: !!st.spot && (mode === "step" || mode === "whatsthis")
readonly property var anchorRect: (mode === "welcome" || mode === "hint") ? (st.anchor || null) : null
readonly property real dim: mode === "step" ? 0.55 : mode === "hub" || mode === "done" ? 0.5
: mode === "whatsthis" ? 0.28 : 0
// Spotlight geometry, animated between steps.
property real sx: st.spot ? st.spot.x : 0
property real sy: st.spot ? st.spot.y : 0
property real sw: st.spot ? st.spot.w : 0
property real sh: st.spot ? st.spot.h : 0
Behavior on sx { enabled: root.mode === "step"; NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
Behavior on sy { enabled: root.mode === "step"; NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
Behavior on sw { enabled: root.mode === "step"; NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
Behavior on sh { enabled: root.mode === "step"; NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
function statusTone(status) {
return status === "done" ? "success" : status === "in_progress" ? "warning" : "info"
}
// ---------------------------------------------------------------- input behind the card
MouseArea {
anchors.fill: parent
enabled: root.mode !== "hidden"
onClicked: {
if (root.mode === "whatsthis")
root.backend.trigger("anchor", "-1")
else if (root.mode === "hub" || root.mode === "done")
root.backend.trigger("close", "")
}
}
// ---------------------------------------------------------------- dimming with a hole
Shape {
anchors.fill: parent
visible: root.dim > 0
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: Qt.rgba(0, 0, 0, root.dim)
strokeWidth: -1
fillRule: ShapePath.OddEvenFill
PathRectangle { x: 0; y: 0; width: root.width; height: root.height }
PathRectangle {
x: root.hasSpot ? root.sx : -10
y: root.hasSpot ? root.sy : -10
width: root.hasSpot ? root.sw : 1
height: root.hasSpot ? root.sh : 1
radius: 10
}
}
}
// ---------------------------------------------------------------- "What's this?" outlines
Repeater {
model: root.mode === "whatsthis" ? (root.st.anchors || []) : []
delegate: Item {
id: anchorItem
required property var modelData
required property int index
x: modelData.rect.x
y: modelData.rect.y
width: modelData.rect.w
height: modelData.rect.h
visible: index !== root.st.selected
Shape {
anchors.fill: parent
ShapePath {
strokeColor: theme.primary
strokeWidth: 1.5
strokeStyle: ShapePath.DashLine
dashPattern: [4, 3]
fillColor: "transparent"
PathRectangle { x: 1; y: 1; width: anchorItem.width - 2; height: anchorItem.height - 2; radius: 6 }
}
}
Rectangle {
width: 14; height: 14; radius: 7
x: parent.width - 9; y: -5
color: theme.primary
Text {
anchors.centerIn: parent
text: "?"
color: theme.onPrimary
font.pixelSize: 10
font.bold: true
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.WhatsThisCursor
onClicked: root.backend.trigger("anchor", String(index))
}
}
}
// ---------------------------------------------------------------- rings
component Ring: Item {
id: ring
property real rx
property real ry
property real rw
property real rh
x: rx; y: ry; width: rw; height: rh
Rectangle { anchors.fill: parent; anchors.margins: -5; radius: 15; color: "transparent"; border.width: 10; border.color: Qt.alpha(theme.primary, 0.16) }
Rectangle { anchors.fill: parent; anchors.margins: -3; radius: 13; color: "transparent"; border.width: 6; border.color: Qt.alpha(theme.primary, 0.28) }
Rectangle { anchors.fill: parent; radius: 10; color: "transparent"; border.width: 2; border.color: theme.primary }
}
Ring {
visible: root.hasSpot
rx: root.sx; ry: root.sy; rw: root.sw; rh: root.sh
}
Ring {
visible: root.anchorRect !== null
rx: root.anchorRect ? root.anchorRect.x - 4 : 0
ry: root.anchorRect ? root.anchorRect.y - 4 : 0
rw: root.anchorRect ? root.anchorRect.w + 8 : 0
rh: root.anchorRect ? root.anchorRect.h + 8 : 0
MouseArea { anchors.fill: parent; onClicked: root.backend.trigger("hub", "") }
}
// ---------------------------------------------------------------- card
readonly property var cardWidths: ({ step: 340, welcome: 380, hint: 330, hub: 560, done: 380, whatsthis: 320 })
property var placement: ({ x: 0, y: 0, side: "center", pointer: 0 })
function relayout() {
if (mode === "hidden" || !loader.item)
return
placement = backend.place(mode, card.width, card.height)
backend.setCardRect(placement.x, placement.y, card.width, card.height)
}
onStChanged: Qt.callLater(relayout)
onWidthChanged: Qt.callLater(relayout)
onHeightChanged: Qt.callLater(relayout)
// pointer towards the spotlight or the help button
Rectangle {
visible: card.visible && ["right", "left", "below", "above"].indexOf(root.placement.side) >= 0
width: 14; height: 14
rotation: 45
color: theme.card
border.color: theme.border
x: root.placement.side === "right" ? card.x - 7
: root.placement.side === "left" ? card.x + card.width - 7
: card.x + root.placement.pointer - 7
y: root.placement.side === "below" ? card.y - 7
: root.placement.side === "above" ? card.y + card.height - 7
: card.y + root.placement.pointer - 7
}
Rectangle {
id: card
visible: root.mode !== "hidden"
x: root.placement.x
y: root.placement.y
width: root.cardWidths[root.mode] || 340
height: (loader.item ? loader.item.implicitHeight : 0) + 32
radius: theme.radiusLarge + 2
color: theme.card
border.color: theme.border
onHeightChanged: Qt.callLater(root.relayout)
MouseArea { anchors.fill: parent } // swallow clicks on the card background
Loader {
id: loader
x: 18; y: 16
width: parent.width - 36
sourceComponent: root.mode === "step" ? stepCard
: root.mode === "welcome" ? welcomeCard
: root.mode === "hint" ? hintCard
: root.mode === "hub" ? hubCard
: root.mode === "done" ? doneCard
: root.mode === "whatsthis" ? whatsCard : null
onLoaded: Qt.callLater(root.relayout)
}
}
// ---------------------------------------------------------------- building blocks
component Caption: Text {
color: theme.primary
font.pixelSize: 11
font.bold: true
font.capitalization: Font.AllUppercase
elide: Text.ElideRight
}
component Body: Text {
color: theme.fg
font.pixelSize: 13
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
component Muted: Text {
color: theme.fgMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
component Title: Text {
color: theme.fg
font.pixelSize: 16
font.weight: Font.DemiBold
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
component IconTile: Rectangle {
property string iconName: "explore"
property string tone: "primary"
property int size: 36
implicitWidth: size
implicitHeight: size
radius: size / 4
color: (theme.name, theme.toneTint(tone))
Icon { anchors.centerIn: parent; name: parent.iconName; size: parent.size - 14; color: (theme.name, theme.toneText(parent.tone)) }
}
component Header: RowLayout {
property string caption: ""
property string closeTip: "Close"
spacing: 6
Caption { text: parent.caption; Layout.fillWidth: true }
IconButton { iconName: "close"; tip: parent.closeTip; compact: true; onClicked: root.backend.trigger("close", "") }
}
component TourRow: Rectangle {
id: row
required property var tour
property bool detailed: false
Layout.fillWidth: true
implicitHeight: rowLayout.implicitHeight + 18
radius: theme.radiusSmall + 2
color: theme.sunken
RowLayout {
id: rowLayout
anchors.fill: parent
anchors.margins: 9
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 12
IconTile {
Layout.alignment: Qt.AlignTop
iconName: row.tour.icon
size: 34
tone: row.tour.status === "done" ? "success" : "primary"
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text { text: row.tour.title; color: theme.fg; font.pixelSize: 13; font.weight: Font.DemiBold; Layout.fillWidth: true; wrapMode: Text.WordWrap }
Muted { visible: row.detailed; text: row.tour.summary; Layout.fillWidth: true }
RowLayout {
spacing: 8
Muted { text: row.tour.steps + " steps · " + row.tour.minutes + " min"; font.pixelSize: 11 }
StatusPill { visible: row.detailed; text: row.tour.label; tone: root.statusTone(row.tour.status); outlined: true }
}
LinearProgress {
visible: row.detailed && row.tour.status === "in_progress"
Layout.fillWidth: true
value: row.tour.progress
tone: "warning"
thickness: 4
}
}
TextButton {
compact: true
text: row.tour.status === "new" ? "Start" : row.tour.status === "in_progress" ? "Resume" : "Replay"
variant: row.tour.status === "in_progress" ? "primary" : "neutral"
onClicked: root.backend.trigger(row.tour.status === "done" ? "start" : "resume", row.tour.id)
}
}
}
// ---------------------------------------------------------------- cards per mode
Component {
id: stepCard
ColumnLayout {
spacing: 10
Header { caption: root.st.tourTitle || ""; closeTip: "Close tour (resume later)"; Layout.fillWidth: true }
Title { text: root.st.title || "" }
Body { text: root.st.text || "" }
Rectangle {
visible: !!root.st.hint
Layout.fillWidth: true
implicitHeight: hintRow.implicitHeight + 16
radius: theme.radiusSmall
color: (theme.name, theme.toneTint("primary"))
RowLayout {
id: hintRow
anchors.fill: parent
anchors.margins: 8
anchors.leftMargin: 10
spacing: 8
Icon { name: "touch_app"; color: theme.primary; size: 18; Layout.alignment: Qt.AlignTop }
Text { text: "Try it: " + (root.st.hint || ""); color: theme.fg; font.pixelSize: 12; wrapMode: Text.WordWrap; Layout.fillWidth: true }
}
}
Row {
Layout.fillWidth: true
Layout.topMargin: 2
spacing: 4
Repeater {
model: root.st.count || 0
Rectangle {
required property int index
width: (parent.width - 4 * ((root.st.count || 1) - 1)) / (root.st.count || 1)
height: 6
radius: 3
color: index <= root.st.index ? theme.primary : theme.track
Behavior on color { ColorAnimation { duration: 160 } }
}
}
}
RowLayout {
spacing: 8
Muted { text: ((root.st.index || 0) + 1) + " of " + (root.st.count || 1) }
Item { Layout.fillWidth: true }
TextButton { text: "Back"; variant: "ghost"; compact: true; enabled: (root.st.index || 0) > 0; onClicked: root.backend.trigger("back", "") }
TextButton {
text: (root.st.index || 0) >= (root.st.count || 1) - 1 ? "Finish" : "Next"
variant: "primary"
compact: true
onClicked: root.backend.trigger("next", "")
}
}
}
}
Component {
id: welcomeCard
ColumnLayout {
spacing: 10
Header { caption: "Welcome to BEC"; closeTip: "Close (show again next time)"; Layout.fillWidth: true }
Title { text: "New here? Let us show you around." }
Muted { text: "Short tours of a minute or two, one task each. Pick one now, or find them later under Help (F1)."; Layout.fillWidth: true }
Repeater {
model: (root.st.tours || []).slice(0, 4)
TourRow { required property var modelData; tour: modelData }
}
RowLayout {
spacing: 8
TextButton { text: "Don't show again"; variant: "ghost"; compact: true; onClicked: root.backend.trigger("never", "") }
Item { Layout.fillWidth: true }
TextButton { text: "Later"; compact: true; onClicked: root.backend.trigger("close", "") }
TextButton {
readonly property var first: (root.st.tours || []).filter(t => t.status !== "done")[0]
visible: !!first
text: "Show me around"
variant: "primary"
compact: true
onClicked: root.backend.trigger("resume", first.id)
}
}
}
}
Component {
id: hintCard
ColumnLayout {
id: hintRoot
readonly property var tour: root.st.tour || ({})
spacing: 10
RowLayout {
spacing: 12
IconTile { iconName: hintRoot.tour.icon || "explore"; Layout.alignment: Qt.AlignTop }
ColumnLayout {
spacing: 2
Layout.fillWidth: true
Text { text: "New here? " + (hintRoot.tour.title || ""); color: theme.fg; font.pixelSize: 13; font.weight: Font.DemiBold; wrapMode: Text.WordWrap; Layout.fillWidth: true }
Muted { text: "A " + (hintRoot.tour.steps || 0) + "-step tour of this view, about " + (hintRoot.tour.minutes || 1) + " min."; Layout.fillWidth: true }
}
IconButton { iconName: "close"; tip: "Not now"; compact: true; Layout.alignment: Qt.AlignTop; onClicked: root.backend.trigger("close", "") }
}
RowLayout {
spacing: 8
Item { Layout.fillWidth: true }
TextButton { text: "Not now"; variant: "ghost"; compact: true; onClicked: root.backend.trigger("close", "") }
TextButton { text: "Start tour"; variant: "primary"; compact: true; onClicked: root.backend.trigger("start", (root.st.tour || {}).id || "") }
}
}
}
Component {
id: hubCard
ColumnLayout {
spacing: 10
RowLayout {
spacing: 12
IconTile { iconName: "school"; size: 40; Layout.alignment: Qt.AlignTop }
ColumnLayout {
spacing: 2
Layout.fillWidth: true
Text { text: "Tours"; color: theme.fg; font.pixelSize: 20; font.weight: Font.DemiBold }
Muted { text: "Short guided tours, one task each. Your progress is saved."; Layout.fillWidth: true }
}
IconButton { iconName: "close"; tip: "Close"; compact: true; Layout.alignment: Qt.AlignTop; onClicked: root.backend.trigger("close", "") }
}
RowLayout {
spacing: 10
LinearProgress { Layout.fillWidth: true; tone: "success"; value: (root.st.done || 0) / Math.max(1, root.st.total || 1) }
Muted { text: (root.st.done || 0) + " of " + (root.st.total || 0) + " done" }
}
Repeater {
model: root.st.tours || []
TourRow { required property var modelData; tour: modelData; detailed: true }
}
Divider {}
RowLayout {
spacing: 8
TextButton { text: "What's this? Shift+F1"; iconName: "help_center"; compact: true; onClicked: root.backend.trigger("whatsthis", "") }
Item { Layout.fillWidth: true }
TextButton { text: "Reset progress"; variant: "ghost"; compact: true; onClicked: root.backend.trigger("reset", "") }
}
}
}
Component {
id: doneCard
ColumnLayout {
spacing: 10
RowLayout {
spacing: 12
IconTile { iconName: "check_circle"; tone: "success"; size: 40; Layout.alignment: Qt.AlignTop }
ColumnLayout {
spacing: 2
Layout.fillWidth: true
Title { text: "Tour complete" }
Muted { text: "You finished “" + (root.st.finished || "") + "”."; Layout.fillWidth: true }
}
}
Divider { visible: !!root.st.next }
Muted { visible: !!root.st.next; text: "UP NEXT"; font.pixelSize: 11 }
Text {
visible: !!root.st.next
text: root.st.next ? root.st.next.title + " · " + root.st.next.steps + " steps" : ""
color: theme.fg; font.pixelSize: 13; font.weight: Font.Medium
}
RowLayout {
spacing: 8
TextButton { text: "All tours"; variant: "ghost"; compact: true; onClicked: root.backend.trigger("hub", "") }
Item { Layout.fillWidth: true }
TextButton { text: "Close"; compact: true; onClicked: root.backend.trigger("close", "") }
TextButton {
visible: !!root.st.next
text: "Start next"; variant: "primary"; compact: true
onClicked: root.backend.trigger("resume", root.st.next.id)
}
}
}
}
Component {
id: whatsCard
ColumnLayout {
id: whatsRoot
readonly property var anchors_: root.st.anchors || []
readonly property int selected: root.st.selected === undefined ? -1 : root.st.selected
readonly property var current: selected >= 0 && selected < anchors_.length ? anchors_[selected] : null
spacing: 10
RowLayout {
visible: !whatsRoot.current
spacing: 10
Icon { name: "help_center"; color: theme.primary; size: 20 }
Body { text: "What's this? Click any outlined control (" + whatsRoot.anchors_.length + " on screen)." }
TextButton { text: "Done"; compact: true; onClicked: root.backend.trigger("close", "") }
}
Header { visible: !!whatsRoot.current; caption: "What's this?"; closeTip: "Leave What's this"; Layout.fillWidth: true }
Text {
visible: !!whatsRoot.current
text: whatsRoot.current ? whatsRoot.current.title : ""
color: theme.fg; font.pixelSize: 15; font.weight: Font.DemiBold
Layout.fillWidth: true; wrapMode: Text.WordWrap
}
Body { visible: !!whatsRoot.current; text: whatsRoot.current ? whatsRoot.current.text : "" }
RowLayout {
visible: !!whatsRoot.current && !!whatsRoot.current.tour_id
spacing: 8
Muted { text: whatsRoot.current ? "Part of “" + whatsRoot.current.tour_title + "”" : ""; Layout.fillWidth: true }
TextButton { text: "Take the tour"; variant: "primary"; compact: true; onClicked: root.backend.trigger("resume", whatsRoot.current.tour_id) }
}
}
}
}
+316
View File
@@ -0,0 +1,316 @@
"""Tests of the tour guide (short tours, progress, welcome, hints, What's this and both overlays)."""
# pylint: disable=redefined-outer-name,protected-access
import pytest
from qtpy.QtCore import QPoint, QRect, QSettings, QSize
from qtpy.QtWidgets import QLabel, QMainWindow, QPushButton, QVBoxLayout, QWidget
from bec_widgets.utils.tour_guide.geometry import interaction_region, place_card, target_rect
from bec_widgets.utils.tour_guide.guide import TourGuide, tour_ui_from_env
from bec_widgets.utils.tour_guide.model import Tour, TourProgress, TourStep
@pytest.fixture
def settings(tmp_path):
return QSettings(str(tmp_path / "tours.ini"), QSettings.Format.IniFormat)
class Window(QMainWindow):
def __init__(self):
super().__init__()
central = QWidget()
layout = QVBoxLayout(central)
self.first = QPushButton("First")
self.second = QPushButton("Second")
self.hidden = QPushButton("Hidden")
self.help = QPushButton("Help")
for widget in (self.first, self.second, self.hidden, QLabel("text"), self.help):
layout.addWidget(widget)
self.hidden.hide()
self.setCentralWidget(central)
self.view = "main"
@pytest.fixture(params=["qwidget", "qml"])
def guide(request, qtbot, settings):
window = Window()
qtbot.addWidget(window)
window.resize(900, 600)
window.show()
qtbot.waitExposed(window)
switched = []
def switch(view):
switched.append(view)
window.view = view
guide = TourGuide(
window,
ui=request.param,
settings=settings,
view_switcher=switch,
current_view=lambda: window.view,
)
guide.switched = switched
guide.register_tour(
Tour(
id="basics",
title="Basics",
summary="The buttons.",
order=0,
steps=[
TourStep("First", "The first button.", target=window.first, hint="Click it."),
TourStep("Hidden", "Never visible.", target=window.hidden),
TourStep("Second", "The second button.", target=lambda: window.second),
TourStep("Overview", "No target, centred card."),
],
)
)
guide.register_tour(
Tour(
id="other",
title="Other view",
summary="Lives in another view.",
view="other",
order=1,
steps=[TourStep("Help", "The help button.", target=window.help, view="other")],
)
)
guide.register_tour(
Tour(
id="unavailable",
title="Never",
summary="No available step.",
steps=[TourStep("x", "y", target=window.first, available=lambda: False)],
)
)
guide.set_help_anchor(window.help)
yield guide
guide.close()
guide.cleanup()
def test_progress_store_round_trip(settings):
progress = TourProgress(settings)
assert progress.completed() == []
assert progress.position("a") is None
progress.save_position("a", 3)
assert progress.position("a") == 3
progress.mark_completed("a")
assert progress.is_completed("a")
assert progress.position("a") is None
progress.welcome_dismissed = True
progress.mark_offered("b")
again = TourProgress(settings)
assert again.welcome_dismissed
assert again.offered() == ["b"]
again.reset()
assert not again.welcome_dismissed and again.completed() == []
def test_tours_hide_tours_without_available_steps(guide):
assert [tour.id for tour in guide.tours()] == ["basics", "other"]
def test_tour_skips_invisible_steps_and_counts_correctly(guide):
assert guide.start_tour("basics")
state = guide.state()
assert state["mode"] == "step"
assert state["title"] == "First"
assert state["hint"] == "Click it."
assert state["count"] == 4
assert state["spot"].contains(guide._window.first.geometry().center())
guide.next_step()
state = guide.state()
assert state["title"] == "Second" # the hidden button is skipped ...
assert state["count"] == 3 # ... and no longer counted
assert state["index"] == 1
guide.next_step()
assert guide.state()["spot"] is None
guide.prev_step()
assert guide.state()["title"] == "Second"
def test_close_keeps_position_and_resume_continues(guide):
guide.start_tour("basics")
guide.next_step()
guide.close()
assert guide.mode == "hidden"
status = guide.tour_status(guide.tour("basics"))
assert status["status"] == "in_progress"
assert status["label"].startswith("Step 2")
guide.resume_tour("basics")
assert guide.current_index == 1
def test_finish_marks_done_and_suggests_next(guide):
guide.start_tour("basics", 3)
guide.next_step()
assert guide.mode == "done"
state = guide.state()
assert state["finished"] == "Basics"
assert state["next"]["id"] == "other"
assert guide.tour_status(guide.tour("basics"))["status"] == "done"
def test_step_in_other_view_switches_view(guide):
guide.start_tour("other")
assert guide.switched == ["other"]
assert guide.state()["title"] == "Help"
def test_welcome_is_shown_until_dismissed(guide, settings):
assert guide.maybe_show_welcome()
state = guide.state()
assert state["mode"] == "welcome"
assert [tour["id"] for tour in state["tours"]] == ["basics", "other"]
assert state["anchor"] is not None
guide._on_overlay_action("never", "")
assert guide.mode == "hidden"
assert TourProgress(settings).welcome_dismissed
assert not guide.maybe_show_welcome()
def test_view_hint_is_offered_once(guide):
guide.notify_view_entered("other")
assert guide.mode == "hint"
assert guide.state()["tour"]["id"] == "other"
guide.close()
guide.notify_view_entered("other")
assert guide.mode == "hidden"
def test_hub_lists_status_and_actions_route(guide):
guide.open_hub()
state = guide.state()
assert state["mode"] == "hub"
assert state["done"] == 0 and state["total"] == 2
guide._on_overlay_action("start", "basics")
assert guide.is_active
guide._on_overlay_action("hub", "")
assert guide.mode == "hub"
def test_whats_this_collects_visible_documented_controls(guide):
guide._window.view = "main"
guide.toggle_whats_this()
state = guide.state()
titles = [anchor["title"] for anchor in state["anchors"]]
assert titles == ["First", "Second"] # hidden, other-view and target-less steps are left out
guide.select_anchor(1)
state = guide.state()
assert state["selected"] == 1
assert state["spot"] is not None
guide.toggle_whats_this()
assert guide.mode == "hidden"
def test_palette_and_welcome_hooks(guide):
commands = guide.palette_commands()
uids = [command["uid"] for command in commands]
assert uids == ["tour:hub", "tour:whatsthis", "tour:basics", "tour:other"]
commands[2]["callback"]()
assert guide.is_active
items = guide.welcome_items()
assert items[0]["id"] == "basics" and items[0]["steps"] == 4
guide.close()
items[1]["start"]()
assert guide.current_tour.id == "other"
def test_overlay_masks_let_the_app_work(guide, qtbot):
window = guide._window
guide.maybe_show_welcome()
qtbot.wait(100)
overlay = guide.overlay
center = window.rect().center()
# the welcome card only takes clicks on itself
assert not overlay.mask().contains(QPoint(center.x(), 5))
guide.start_tour("basics")
qtbot.wait(100)
spot = guide.state()["spot"]
# the highlighted control stays clickable during a step, the rest is blocked
assert not overlay.mask().contains(spot.center())
assert overlay.mask().contains(QPoint(window.width() - 5, window.height() - 5))
def test_keyboard_shortcuts_only_while_blocking(guide):
assert not any(shortcut.isEnabled() for shortcut in guide._shortcuts)
guide.start_tour("basics")
assert all(shortcut.isEnabled() for shortcut in guide._shortcuts)
guide.close()
guide.maybe_show_welcome()
assert not any(shortcut.isEnabled() for shortcut in guide._shortcuts)
def test_place_card_avoids_spot():
bounds = QRect(0, 0, 1000, 700)
spot = QRect(10, 10, 100, 40)
position, side = place_card(spot, QSize(300, 200), bounds)
assert side == "right"
assert not QRect(position, QSize(300, 200)).intersects(spot)
position, side = place_card(QRect(850, 300, 140, 40), QSize(300, 200), bounds)
assert side == "left"
position, side = place_card(None, QSize(300, 200), bounds)
assert side == "center" and abs(position.x() - 350) <= 1 and abs(position.y() - 250) <= 1
def test_interaction_region_modes():
bounds = QRect(0, 0, 400, 300)
spot = QRect(10, 10, 50, 50)
card = QRect(100, 100, 100, 100)
step = interaction_region("step", bounds, spot, [card])
assert not step.contains(QPoint(20, 20)) and step.contains(QPoint(300, 250))
welcome = interaction_region("welcome", bounds, None, [card])
assert welcome.contains(QPoint(150, 150)) and not welcome.contains(QPoint(300, 250))
assert not interaction_region("hint", bounds, None, []).isEmpty()
def test_target_rect_handles_hidden_and_callables(qtbot):
window = Window()
qtbot.addWidget(window)
window.show()
qtbot.waitExposed(window)
assert target_rect(window.hidden, window) is None
assert target_rect(lambda: (window.first, "text"), window) is not None
assert target_rect(QRect(1, 1, 20, 20), window) == QRect(1, 1, 20, 20)
def test_tour_ui_from_env(monkeypatch):
monkeypatch.delenv("BEC_TOUR_UI", raising=False)
assert tour_ui_from_env() is None
monkeypatch.setenv("BEC_TOUR_UI", "QML")
assert tour_ui_from_env() == "qml"
monkeypatch.setenv("BEC_TOUR_UI", "nope")
assert tour_ui_from_env() is None
@pytest.mark.parametrize("ui", ["qwidget", "qml"])
def test_main_app_entry_points(qtbot, mocked_client, settings, ui):
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QMenu
from bec_widgets.applications.main_app import BECMainApp
app = BECMainApp(client=mocked_client, anim_duration=10)
qtbot.addWidget(app)
app._setup_tour_guide(ui, settings=settings, first_run=False)
app.show()
qtbot.waitExposed(app)
guide = app.tour_guide
assert {tour.id for tour in guide.tours()} >= {"first_steps", "workspace", "device_config"}
assert "help" in app.sidebar.components
help_menu = next(menu for menu in app.menuBar().findChildren(QMenu) if menu.title() == "Help")
shortcuts = {action.text(): action.shortcut().toString() for action in help_menu.actions()}
assert shortcuts["Tours…"] == "F1"
assert shortcuts["What's This?"] == "Shift+F1"
assert "Classic Guided Tour" in shortcuts
app.sidebar.components["help"].activated.emit()
assert guide.mode == "hub"
assert guide.start_tour("workspace")
assert guide.state()["spot"] is not None
devices = next(tour for tour in guide.tours() if tour.id == "device_config")
guide.start_tour(devices.id)
assert app._current_view_id == devices.view
guide.close()