From 78c4bf2b10ece2c6eb4fa139bca263ef11f0b624 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 22:29:24 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_0178HeT65Qj2RJqPm1yE4xdB --- bec_widgets/applications/app_tours.py | 237 +++++++ bec_widgets/applications/main_app.py | 77 ++- bec_widgets/utils/tour_guide/README.md | 77 +++ bec_widgets/utils/tour_guide/__init__.py | 10 + bec_widgets/utils/tour_guide/geometry.py | 171 +++++ bec_widgets/utils/tour_guide/guide.py | 633 ++++++++++++++++++ bec_widgets/utils/tour_guide/model.py | 154 +++++ bec_widgets/utils/tour_guide/overlay_qml.py | 164 +++++ .../utils/tour_guide/overlay_qwidget.py | 631 +++++++++++++++++ .../utils/tour_guide/qml/TourOverlay.qml | 489 ++++++++++++++ tests/unit_tests/test_tour_guide.py | 316 +++++++++ 11 files changed, 2958 insertions(+), 1 deletion(-) create mode 100644 bec_widgets/applications/app_tours.py create mode 100644 bec_widgets/utils/tour_guide/README.md create mode 100644 bec_widgets/utils/tour_guide/__init__.py create mode 100644 bec_widgets/utils/tour_guide/geometry.py create mode 100644 bec_widgets/utils/tour_guide/guide.py create mode 100644 bec_widgets/utils/tour_guide/model.py create mode 100644 bec_widgets/utils/tour_guide/overlay_qml.py create mode 100644 bec_widgets/utils/tour_guide/overlay_qwidget.py create mode 100644 bec_widgets/utils/tour_guide/qml/TourOverlay.qml create mode 100644 tests/unit_tests/test_tour_guide.py diff --git a/bec_widgets/applications/app_tours.py b/bec_widgets/applications/app_tours.py new file mode 100644 index 00000000..3574b10f --- /dev/null +++ b/bec_widgets/applications/app_tours.py @@ -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 diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index e238599d..79382f77 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -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() diff --git a/bec_widgets/utils/tour_guide/README.md b/bec_widgets/utils/tour_guide/README.md new file mode 100644 index 00000000..ad6faec2 --- /dev/null +++ b/bec_widgets/utils/tour_guide/README.md @@ -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. diff --git a/bec_widgets/utils/tour_guide/__init__.py b/bec_widgets/utils/tour_guide/__init__.py new file mode 100644 index 00000000..b1321022 --- /dev/null +++ b/bec_widgets/utils/tour_guide/__init__.py @@ -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"] diff --git a/bec_widgets/utils/tour_guide/geometry.py b/bec_widgets/utils/tour_guide/geometry.py new file mode 100644 index 00000000..c6ac334f --- /dev/null +++ b/bec_widgets/utils/tour_guide/geometry.py @@ -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 diff --git a/bec_widgets/utils/tour_guide/guide.py b/bec_widgets/utils/tour_guide/guide.py new file mode 100644 index 00000000..ea58d05d --- /dev/null +++ b/bec_widgets/utils/tour_guide/guide.py @@ -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 diff --git a/bec_widgets/utils/tour_guide/model.py b/bec_widgets/utils/tour_guide/model.py new file mode 100644 index 00000000..398b5a96 --- /dev/null +++ b/bec_widgets/utils/tour_guide/model.py @@ -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() diff --git a/bec_widgets/utils/tour_guide/overlay_qml.py b/bec_widgets/utils/tour_guide/overlay_qml.py new file mode 100644 index 00000000..c8c90823 --- /dev/null +++ b/bec_widgets/utils/tour_guide/overlay_qml.py @@ -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) diff --git a/bec_widgets/utils/tour_guide/overlay_qwidget.py b/bec_widgets/utils/tour_guide/overlay_qwidget.py new file mode 100644 index 00000000..a7d11081 --- /dev/null +++ b/bec_widgets/utils/tour_guide/overlay_qwidget.py @@ -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() diff --git a/bec_widgets/utils/tour_guide/qml/TourOverlay.qml b/bec_widgets/utils/tour_guide/qml/TourOverlay.qml new file mode 100644 index 00000000..4bb05787 --- /dev/null +++ b/bec_widgets/utils/tour_guide/qml/TourOverlay.qml @@ -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) } + } + } + } +} diff --git a/tests/unit_tests/test_tour_guide.py b/tests/unit_tests/test_tour_guide.py new file mode 100644 index 00000000..656aeb8a --- /dev/null +++ b/tests/unit_tests/test_tour_guide.py @@ -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()