mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-27 16:01:27 +01:00
feat(tour_guide): short task tours with welcome card, tour list, view hints and What's this, in QWidget and QML
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178HeT65Qj2RJqPm1yE4xdB
This commit is contained in:
11 files changed
+2958
-1
No files matched your search
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""QML overlay of the tour guide, built from the ``BecUi`` controls.
|
||||
|
||||
Same look and behaviour as :mod:`overlay_qwidget`: the guide hands over a state, the QML scene
|
||||
draws it and reports clicks. Card placement and the click-through region use the same functions
|
||||
from :mod:`geometry` as the QWidget overlay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from qtpy.QtCore import Property, QObject, QRect, QSize, Qt, Signal, Slot
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.quick import create_quick_widget, release_quick_widget
|
||||
from bec_widgets.utils.tour_guide.geometry import interaction_region, place_card, pointer_offset
|
||||
|
||||
QML_FILE = Path(__file__).parent / "qml" / "TourOverlay.qml"
|
||||
|
||||
|
||||
def _rect(rect: QRect | None) -> dict | None:
|
||||
if rect is None or not rect.isValid():
|
||||
return None
|
||||
return {"x": rect.x(), "y": rect.y(), "w": rect.width(), "h": rect.height()}
|
||||
|
||||
|
||||
def to_qml_state(state: dict) -> dict:
|
||||
"""Convert a guide state to plain QML values (rectangles become ``{x, y, w, h}``)."""
|
||||
converted = {}
|
||||
for key, value in state.items():
|
||||
if isinstance(value, QRect):
|
||||
converted[key] = _rect(value)
|
||||
elif key == "anchors":
|
||||
converted[key] = [{**anchor, "rect": _rect(anchor["rect"])} for anchor in value]
|
||||
else:
|
||||
converted[key] = value
|
||||
return converted
|
||||
|
||||
|
||||
class TourOverlayBackend(QObject):
|
||||
"""Bridge between the QML scene and the guide."""
|
||||
|
||||
stateChanged = Signal()
|
||||
triggered = Signal(str, str)
|
||||
cardChanged = Signal()
|
||||
|
||||
def __init__(self, overlay: QmlTourOverlay):
|
||||
super().__init__(overlay)
|
||||
self._overlay = overlay
|
||||
self._state: dict = {"mode": "hidden"}
|
||||
self._raw: dict = {"mode": "hidden"}
|
||||
self._card = QRect()
|
||||
|
||||
@Property("QVariantMap", notify=stateChanged)
|
||||
def state(self) -> dict: # pylint: disable=missing-function-docstring
|
||||
return self._state
|
||||
|
||||
def set_state(self, state: dict) -> None:
|
||||
"""Hand a new guide state to the scene."""
|
||||
self._raw = state
|
||||
self._state = to_qml_state(state)
|
||||
self.stateChanged.emit()
|
||||
|
||||
@Slot(str, str)
|
||||
def trigger(self, action: str, arg: str = "") -> None:
|
||||
"""Called from QML when the user clicks something."""
|
||||
self.triggered.emit(action, arg)
|
||||
|
||||
@Slot(str, int, int, result="QVariantMap")
|
||||
def place(self, mode: str, width: int, height: int) -> dict:
|
||||
"""Card position for ``mode`` at the given size, with the side and pointer offset."""
|
||||
bounds = self._overlay.rect()
|
||||
size = QSize(width, height)
|
||||
if mode in ("welcome", "hint"):
|
||||
anchor = self._raw.get("anchor")
|
||||
if anchor is None:
|
||||
return {
|
||||
"x": bounds.right() - width - 18,
|
||||
"y": bounds.bottom() - height - 40,
|
||||
"side": "corner",
|
||||
"pointer": 0,
|
||||
}
|
||||
elif mode in ("hub", "done"):
|
||||
anchor = None
|
||||
else:
|
||||
anchor = self._raw.get("spot")
|
||||
if mode == "whatsthis" and anchor is None:
|
||||
return {"x": (bounds.width() - width) // 2, "y": 18, "side": "center", "pointer": 0}
|
||||
position, side = place_card(anchor, size, bounds)
|
||||
offset = pointer_offset(anchor, QRect(position, size), side)
|
||||
return {"x": position.x(), "y": position.y(), "side": side, "pointer": offset}
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@Slot(int, int, int, int)
|
||||
def setCardRect(self, x: int, y: int, width: int, height: int) -> None:
|
||||
"""QML reports where the card ended up, for the click-through mask."""
|
||||
self._card = QRect(x, y, width, height)
|
||||
self._overlay.update_mask()
|
||||
|
||||
@property
|
||||
def card_rect(self) -> QRect:
|
||||
"""Last card rectangle reported by QML."""
|
||||
return self._card
|
||||
|
||||
@property
|
||||
def raw_state(self) -> dict:
|
||||
"""The state as the guide sent it."""
|
||||
return self._raw
|
||||
|
||||
|
||||
class QmlTourOverlay(QWidget):
|
||||
"""Full-window overlay drawing the tour guide state with QML.
|
||||
|
||||
Signals:
|
||||
triggered(str, str): Same actions as the QWidget overlay.
|
||||
"""
|
||||
|
||||
triggered = Signal(str, str)
|
||||
|
||||
def __init__(self, window: QWidget):
|
||||
super().__init__(window)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
|
||||
self.backend = TourOverlayBackend(self)
|
||||
self.backend.triggered.connect(self.triggered)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.view = create_quick_widget(
|
||||
self, QML_FILE, properties={"backend": self.backend}, background="transparent"
|
||||
)
|
||||
self.view.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, True)
|
||||
self.view.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||||
layout.addWidget(self.view)
|
||||
self.setGeometry(window.rect())
|
||||
self.hide()
|
||||
|
||||
def apply_state(self, state: dict) -> None:
|
||||
"""Draw ``state`` (see ``TourGuide.state``)."""
|
||||
mode = state.get("mode", "hidden")
|
||||
if mode == "hidden":
|
||||
self.backend.set_state(state)
|
||||
self.hide()
|
||||
return
|
||||
self.setGeometry(self.parentWidget().rect())
|
||||
self.backend.set_state(state)
|
||||
self.show()
|
||||
self.raise_()
|
||||
self.update_mask()
|
||||
|
||||
def update_mask(self) -> None:
|
||||
"""Let clicks through where the guide wants the app to stay usable."""
|
||||
state = self.backend.raw_state
|
||||
mode = state.get("mode", "hidden")
|
||||
cards = [self.backend.card_rect] if self.backend.card_rect.isValid() else []
|
||||
if mode in ("welcome", "hint"):
|
||||
cards = [rect.adjusted(-12, -12, 12, 12) for rect in cards]
|
||||
anchor = state.get("anchor")
|
||||
if anchor is not None:
|
||||
cards.append(anchor.adjusted(-8, -8, 8, 8))
|
||||
self.setMask(interaction_region(mode, self.rect(), state.get("spot"), cards))
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Unload the scene."""
|
||||
release_quick_widget(self.view)
|
||||
@@ -0,0 +1,631 @@
|
||||
"""QWidget overlay of the tour guide, built from the ``ux_kit`` controls.
|
||||
|
||||
The overlay covers the window, paints the dimming, spotlight and "What's this?" outlines, and
|
||||
shows one card at a time. It only draws the state handed to :meth:`TourOverlay.apply_state` and
|
||||
reports clicks through :attr:`TourOverlay.triggered`; the logic lives in ``TourGuide``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QEasingCurve, QPoint, QRect, QRectF, QSize, Qt, QVariantAnimation, Signal
|
||||
from qtpy.QtGui import QColor, QFont, QPainter, QPainterPath, QPen, QPolygon
|
||||
from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.quick.tokens import ThemeTokens
|
||||
from bec_widgets.utils.tour_guide.geometry import interaction_region, place_card, pointer_offset
|
||||
from bec_widgets.utils.ux_kit import (
|
||||
Divider,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
StatusPill,
|
||||
TextButton,
|
||||
_ThemeFollower,
|
||||
)
|
||||
|
||||
STATUS_TONES = {"new": "info", "in_progress": "warning", "done": "success"}
|
||||
CARD_WIDTHS = {"step": 340, "welcome": 380, "hint": 330, "hub": 560, "done": 380, "whatsthis": 320}
|
||||
|
||||
|
||||
def _label(text: str = "", size: int = 13, weight: QFont.Weight | None = None, wrap=True) -> QLabel:
|
||||
label = QLabel(text)
|
||||
font = QFont(label.font())
|
||||
font.setPixelSize(size)
|
||||
if weight is not None:
|
||||
font.setWeight(weight)
|
||||
label.setFont(font)
|
||||
label.setWordWrap(wrap)
|
||||
label.setTextFormat(Qt.TextFormat.PlainText)
|
||||
return label
|
||||
|
||||
|
||||
def _icon_tile(name: str, tokens: ThemeTokens, size: int = 36, tone: str = "primary") -> QLabel:
|
||||
tile = QLabel()
|
||||
tile.setFixedSize(size, size)
|
||||
tile.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
tile.setPixmap(
|
||||
material_icon(
|
||||
name, size=(size - 14, size - 14), color=tokens.tone_text(tone), convert_to_pixmap=True
|
||||
)
|
||||
)
|
||||
tile.setStyleSheet(
|
||||
f"background: {tokens.tone_tint(tone).name(QColor.NameFormat.HexArgb)};"
|
||||
f" border-radius: {size // 4}px;"
|
||||
)
|
||||
return tile
|
||||
|
||||
|
||||
class StepDots(QWidget):
|
||||
"""Segmented progress: one bar per step, done steps filled."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self._index = 0
|
||||
self._count = 1
|
||||
self.setFixedHeight(6)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
|
||||
def set_progress(self, index: int, count: int) -> None:
|
||||
"""Highlight steps up to ``index`` of ``count``."""
|
||||
self._index, self._count = index, max(1, count)
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, _event): # pylint: disable=invalid-name
|
||||
"""Paint one rounded bar per step."""
|
||||
tokens = ThemeTokens.current()
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
gap = 4
|
||||
width = (self.width() - gap * (self._count - 1)) / self._count
|
||||
for i in range(self._count):
|
||||
color = tokens.primary if i <= self._index else tokens.track
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(color)
|
||||
painter.drawRoundedRect(QRectF(i * (width + gap), 0, width, 6), 3, 3)
|
||||
painter.end()
|
||||
|
||||
|
||||
class OverlayCard(_ThemeFollower, QFrame):
|
||||
"""Surface of every overlay card; rebuilt from the state on each update."""
|
||||
|
||||
triggered = Signal(str, str)
|
||||
|
||||
def __init__(self, parent: QWidget, mode: str):
|
||||
super().__init__(parent)
|
||||
self.mode = mode
|
||||
self.setObjectName("tourCard")
|
||||
self.setFixedWidth(CARD_WIDTHS[mode])
|
||||
self._layout = QVBoxLayout(self)
|
||||
self._layout.setContentsMargins(18, 16, 18, 16)
|
||||
self._layout.setSpacing(10)
|
||||
self._state: dict = {}
|
||||
self.refresh_theme(ThemeTokens.current())
|
||||
self._follow_theme()
|
||||
|
||||
def refresh_theme(self, tokens: ThemeTokens) -> None:
|
||||
"""Apply the theme tokens."""
|
||||
self.setStyleSheet(
|
||||
f"#tourCard {{ background: {tokens.card.name()}; border: 1px solid"
|
||||
f" {tokens.border.name()}; border-radius: {tokens.metrics['radiusLarge'] + 2}px; }}"
|
||||
f"#tourCard QLabel {{ color: {tokens.fg.name()}; background: transparent; }}"
|
||||
f'#tourCard QLabel[muted="true"] {{ color: {tokens.fg_muted.name()}; }}'
|
||||
f'#tourCard QLabel[caption="true"] {{ color: {tokens.primary.name()}; }}'
|
||||
)
|
||||
if self._state:
|
||||
self.set_state(self._state)
|
||||
|
||||
def _emit(self, action: str, arg: str = "") -> None:
|
||||
self.triggered.emit(action, arg)
|
||||
|
||||
def _clear(self) -> None:
|
||||
def clear(layout):
|
||||
while layout.count():
|
||||
item = layout.takeAt(0)
|
||||
if item.widget() is not None:
|
||||
item.widget().hide()
|
||||
item.widget().deleteLater()
|
||||
elif item.layout() is not None:
|
||||
clear(item.layout())
|
||||
|
||||
clear(self._layout)
|
||||
|
||||
def _button( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
self, text, variant, action, arg="", icon="", compact=False
|
||||
) -> TextButton:
|
||||
button = TextButton(text, variant, icon, compact=compact)
|
||||
button.clicked.connect(lambda: self._emit(action, arg))
|
||||
return button
|
||||
|
||||
def _muted(self, text: str, size: int = 12) -> QLabel:
|
||||
label = _label(text, size)
|
||||
label.setProperty("muted", True)
|
||||
return label
|
||||
|
||||
def _header(self, caption: str, close_tip: str = "Close") -> QHBoxLayout:
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(6)
|
||||
label = _label(caption.upper(), 11, QFont.Weight.Bold, wrap=False)
|
||||
label.setProperty("caption", True)
|
||||
row.addWidget(label, 1)
|
||||
close = IconButton("close", close_tip, compact=True)
|
||||
close.clicked.connect(lambda: self._emit("close"))
|
||||
row.addWidget(close)
|
||||
return row
|
||||
|
||||
def set_state(self, state: dict) -> None:
|
||||
"""Rebuild the card for ``state``."""
|
||||
self._state = state
|
||||
self._clear()
|
||||
tokens = ThemeTokens.current()
|
||||
getattr(self, f"_build_{self.mode}")(state, tokens)
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
|
||||
def preferred_size(self) -> QSize:
|
||||
"""Size of the card at its fixed width."""
|
||||
width = self.width()
|
||||
layout = self.layout()
|
||||
height = layout.totalHeightForWidth(width) if layout.hasHeightForWidth() else 0
|
||||
return QSize(width, max(height, layout.totalSizeHint().height()))
|
||||
|
||||
# -- cards ---------------------------------------------------------------
|
||||
def _build_step(self, state: dict, tokens: ThemeTokens) -> None:
|
||||
self._layout.addLayout(
|
||||
self._header(state.get("tourTitle", ""), "Close tour (resume later)")
|
||||
)
|
||||
self._layout.addWidget(_label(state.get("title", ""), 16, QFont.Weight.DemiBold))
|
||||
self._layout.addWidget(_label(state.get("text", ""), 13))
|
||||
if state.get("hint"):
|
||||
hint = QFrame()
|
||||
hint.setStyleSheet(
|
||||
f"background: {tokens.tone_tint('primary').name(QColor.NameFormat.HexArgb)};"
|
||||
f" border-radius: {tokens.metrics['radiusSmall']}px;"
|
||||
)
|
||||
row = QHBoxLayout(hint)
|
||||
row.setContentsMargins(10, 8, 10, 8)
|
||||
icon = QLabel()
|
||||
icon.setPixmap(
|
||||
material_icon(
|
||||
"touch_app", size=(18, 18), color=tokens.primary, convert_to_pixmap=True
|
||||
)
|
||||
)
|
||||
row.addWidget(icon, 0, Qt.AlignmentFlag.AlignTop)
|
||||
text = _label(f"Try it: {state['hint']}", 12)
|
||||
row.addWidget(text, 1)
|
||||
self._layout.addWidget(hint)
|
||||
index, count = state.get("index", 0), state.get("count", 1)
|
||||
dots = StepDots()
|
||||
dots.set_progress(index, count)
|
||||
self._layout.addSpacing(2)
|
||||
self._layout.addWidget(dots)
|
||||
footer = QHBoxLayout()
|
||||
footer.setSpacing(8)
|
||||
footer.addWidget(self._muted(f"{index + 1} of {count}"))
|
||||
footer.addStretch(1)
|
||||
back = self._button("Back", "ghost", "back", compact=True)
|
||||
back.setEnabled(index > 0)
|
||||
footer.addWidget(back)
|
||||
last = index >= count - 1
|
||||
footer.addWidget(
|
||||
self._button("Finish" if last else "Next", "primary", "next", compact=True)
|
||||
)
|
||||
self._layout.addLayout(footer)
|
||||
|
||||
def _tour_row(self, tour: dict, tokens: ThemeTokens, detailed: bool) -> QWidget:
|
||||
row = QFrame()
|
||||
row.setObjectName("tourRow")
|
||||
row.setStyleSheet(
|
||||
f"#tourRow {{ background: {tokens.sunken.name()}; border-radius:"
|
||||
f" {tokens.metrics['radiusSmall'] + 2}px; }}"
|
||||
)
|
||||
layout = QHBoxLayout(row)
|
||||
layout.setContentsMargins(10, 9, 10, 9)
|
||||
layout.setSpacing(12)
|
||||
tone = STATUS_TONES[tour["status"]] if tour["status"] == "done" else "primary"
|
||||
layout.addWidget(_icon_tile(tour["icon"], tokens, 34, tone), 0, Qt.AlignmentFlag.AlignTop)
|
||||
text = QVBoxLayout()
|
||||
text.setSpacing(2)
|
||||
text.addWidget(_label(tour["title"], 13, QFont.Weight.DemiBold))
|
||||
if detailed:
|
||||
text.addWidget(self._muted(tour["summary"], 12))
|
||||
meta = QHBoxLayout()
|
||||
meta.setSpacing(8)
|
||||
meta.addWidget(self._muted(f"{tour['steps']} steps · {tour['minutes']} min", 11))
|
||||
if detailed:
|
||||
meta.addWidget(
|
||||
StatusPill(text=tour["label"], tone=STATUS_TONES[tour["status"]], outlined=True)
|
||||
)
|
||||
meta.addStretch(1)
|
||||
text.addLayout(meta)
|
||||
if detailed and tour["status"] == "in_progress":
|
||||
progress_bar = LinearProgress(tone="warning", thickness=4)
|
||||
progress_bar.set_value(tour["progress"], animate=False)
|
||||
text.addWidget(progress_bar)
|
||||
layout.addLayout(text, 1)
|
||||
verb = {"new": "Start", "in_progress": "Resume", "done": "Replay"}[tour["status"]]
|
||||
action = "start" if tour["status"] == "done" else "resume"
|
||||
variant = "primary" if tour["status"] == "in_progress" else "neutral"
|
||||
layout.addWidget(
|
||||
self._button(verb, variant, action, tour["id"], compact=True),
|
||||
0,
|
||||
Qt.AlignmentFlag.AlignVCenter,
|
||||
)
|
||||
return row
|
||||
|
||||
def _build_welcome(self, state: dict, tokens: ThemeTokens) -> None:
|
||||
self._layout.addLayout(self._header("Welcome to BEC", "Close (show again next time)"))
|
||||
self._layout.addWidget(
|
||||
_label("New here? Let us show you around.", 16, QFont.Weight.DemiBold)
|
||||
)
|
||||
self._layout.addWidget(
|
||||
self._muted(
|
||||
"Short tours of a minute or two, one task each. Pick one now, or find them later"
|
||||
" under Help (F1).",
|
||||
12,
|
||||
)
|
||||
)
|
||||
for tour in state.get("tours", [])[:4]:
|
||||
self._layout.addWidget(self._tour_row(tour, tokens, detailed=False))
|
||||
footer = QHBoxLayout()
|
||||
footer.addWidget(self._button("Don't show again", "ghost", "never", compact=True))
|
||||
footer.addStretch(1)
|
||||
footer.addWidget(self._button("Later", "neutral", "close", compact=True))
|
||||
first = next((t for t in state.get("tours", []) if t["status"] != "done"), None)
|
||||
if first:
|
||||
footer.addWidget(
|
||||
self._button("Show me around", "primary", "resume", first["id"], compact=True)
|
||||
)
|
||||
self._layout.addLayout(footer)
|
||||
|
||||
def _build_hint(self, state: dict, tokens: ThemeTokens) -> None:
|
||||
tour = state.get("tour") or {}
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(12)
|
||||
row.addWidget(
|
||||
_icon_tile(tour.get("icon", "explore"), tokens, 36), 0, Qt.AlignmentFlag.AlignTop
|
||||
)
|
||||
text = QVBoxLayout()
|
||||
text.setSpacing(2)
|
||||
text.addWidget(_label(f"New here? {tour.get('title', '')}", 13, QFont.Weight.DemiBold))
|
||||
text.addWidget(
|
||||
self._muted(
|
||||
f"A {tour.get('steps', 0)}-step tour of this view,"
|
||||
f" about {tour.get('minutes', 1)} min.",
|
||||
12,
|
||||
)
|
||||
)
|
||||
row.addLayout(text, 1)
|
||||
close = IconButton("close", "Not now", compact=True)
|
||||
close.clicked.connect(lambda: self._emit("close"))
|
||||
row.addWidget(close, 0, Qt.AlignmentFlag.AlignTop)
|
||||
self._layout.addLayout(row)
|
||||
footer = QHBoxLayout()
|
||||
footer.addStretch(1)
|
||||
footer.addWidget(self._button("Not now", "ghost", "close", compact=True))
|
||||
footer.addWidget(
|
||||
self._button("Start tour", "primary", "start", tour.get("id", ""), compact=True)
|
||||
)
|
||||
self._layout.addLayout(footer)
|
||||
|
||||
def _build_hub(self, state: dict, tokens: ThemeTokens) -> None:
|
||||
head = QHBoxLayout()
|
||||
head.setSpacing(12)
|
||||
head.addWidget(_icon_tile("school", tokens, 40), 0, Qt.AlignmentFlag.AlignTop)
|
||||
titles = QVBoxLayout()
|
||||
titles.setSpacing(2)
|
||||
titles.addWidget(_label("Tours", 20, QFont.Weight.DemiBold))
|
||||
titles.addWidget(
|
||||
self._muted("Short guided tours, one task each. Your progress is saved.", 12)
|
||||
)
|
||||
head.addLayout(titles, 1)
|
||||
close = IconButton("close", "Close", compact=True)
|
||||
close.clicked.connect(lambda: self._emit("close"))
|
||||
head.addWidget(close, 0, Qt.AlignmentFlag.AlignTop)
|
||||
self._layout.addLayout(head)
|
||||
done, total = state.get("done", 0), max(1, state.get("total", 1))
|
||||
progress = QHBoxLayout()
|
||||
progress_bar = LinearProgress(tone="success", thickness=6)
|
||||
progress_bar.set_value(done / total, animate=False)
|
||||
progress.addWidget(progress_bar, 1)
|
||||
progress.addWidget(self._muted(f"{done} of {state.get('total', 0)} done", 12))
|
||||
self._layout.addLayout(progress)
|
||||
for tour in state.get("tours", []):
|
||||
self._layout.addWidget(self._tour_row(tour, tokens, detailed=True))
|
||||
self._layout.addWidget(Divider())
|
||||
footer = QHBoxLayout()
|
||||
footer.addWidget(
|
||||
self._button(
|
||||
"What's this? Shift+F1", "neutral", "whatsthis", icon="help_center", compact=True
|
||||
)
|
||||
)
|
||||
footer.addStretch(1)
|
||||
footer.addWidget(self._button("Reset progress", "ghost", "reset", compact=True))
|
||||
self._layout.addLayout(footer)
|
||||
|
||||
def _build_done(self, state: dict, tokens: ThemeTokens) -> None:
|
||||
head = QHBoxLayout()
|
||||
head.setSpacing(12)
|
||||
head.addWidget(
|
||||
_icon_tile("check_circle", tokens, 40, "success"), 0, Qt.AlignmentFlag.AlignTop
|
||||
)
|
||||
titles = QVBoxLayout()
|
||||
titles.setSpacing(2)
|
||||
titles.addWidget(_label("Tour complete", 16, QFont.Weight.DemiBold))
|
||||
titles.addWidget(self._muted(f"You finished “{state.get('finished', '')}”.", 12))
|
||||
head.addLayout(titles, 1)
|
||||
self._layout.addLayout(head)
|
||||
following = state.get("next")
|
||||
footer = QHBoxLayout()
|
||||
if following:
|
||||
self._layout.addWidget(Divider())
|
||||
self._layout.addWidget(self._muted("UP NEXT", 11))
|
||||
self._layout.addWidget(
|
||||
_label(
|
||||
f"{following['title']} · {following['steps']} steps", 13, QFont.Weight.Medium
|
||||
)
|
||||
)
|
||||
footer.addWidget(self._button("All tours", "ghost", "hub", compact=True))
|
||||
footer.addStretch(1)
|
||||
footer.addWidget(self._button("Close", "neutral", "close", compact=True))
|
||||
if following:
|
||||
footer.addWidget(
|
||||
self._button("Start next", "primary", "resume", following["id"], compact=True)
|
||||
)
|
||||
self._layout.addLayout(footer)
|
||||
|
||||
def _build_whatsthis(self, state: dict, tokens: ThemeTokens) -> None:
|
||||
anchors, selected = state.get("anchors", []), state.get("selected", -1)
|
||||
if not 0 <= selected < len(anchors):
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(10)
|
||||
icon = QLabel()
|
||||
icon.setPixmap(
|
||||
material_icon(
|
||||
"help_center", size=(20, 20), color=tokens.primary, convert_to_pixmap=True
|
||||
)
|
||||
)
|
||||
row.addWidget(icon)
|
||||
row.addWidget(
|
||||
_label(f"What's this? Click any outlined control ({len(anchors)} on screen).", 13),
|
||||
1,
|
||||
)
|
||||
row.addWidget(self._button("Done", "neutral", "close", compact=True))
|
||||
self._layout.addLayout(row)
|
||||
return
|
||||
anchor = anchors[selected]
|
||||
self._layout.addLayout(self._header("What's this?", "Leave What's this"))
|
||||
self._layout.addWidget(_label(anchor["title"], 15, QFont.Weight.DemiBold))
|
||||
self._layout.addWidget(_label(anchor["text"], 13))
|
||||
if anchor.get("tour_id"):
|
||||
footer = QHBoxLayout()
|
||||
footer.addWidget(self._muted(f"Part of “{anchor['tour_title']}”", 12), 1)
|
||||
footer.addWidget(
|
||||
self._button("Take the tour", "primary", "resume", anchor["tour_id"], compact=True)
|
||||
)
|
||||
self._layout.addLayout(footer)
|
||||
|
||||
|
||||
class TourOverlay(QWidget):
|
||||
"""Full-window overlay drawing the tour guide state with QWidgets.
|
||||
|
||||
Signals:
|
||||
triggered(str, str): An action (``next``, ``back``, ``close``, ``start``, ``resume``,
|
||||
``hub``, ``never``, ``whatsthis``, ``reset`` or ``anchor``) and its argument.
|
||||
"""
|
||||
|
||||
triggered = Signal(str, str)
|
||||
|
||||
def __init__(self, window: QWidget):
|
||||
super().__init__(window)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
|
||||
self.setObjectName("tourOverlay")
|
||||
self._state: dict = {"mode": "hidden"}
|
||||
self._spot = QRect()
|
||||
self._side = "center"
|
||||
self._cards = {mode: OverlayCard(self, mode) for mode in CARD_WIDTHS}
|
||||
for card in self._cards.values():
|
||||
card.triggered.connect(self.triggered)
|
||||
card.hide()
|
||||
self._spot_animation = QVariantAnimation(self)
|
||||
self._spot_animation.setDuration(220)
|
||||
self._spot_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
|
||||
self._spot_animation.valueChanged.connect(self._on_spot_animated)
|
||||
self.setGeometry(window.rect())
|
||||
self.hide()
|
||||
|
||||
@property
|
||||
def card(self) -> OverlayCard | None:
|
||||
"""The visible card."""
|
||||
card = self._cards.get(self._state.get("mode", "hidden"))
|
||||
return card if card is not None and card.isVisible() else None
|
||||
|
||||
def apply_state(self, state: dict) -> None:
|
||||
"""Draw ``state`` (see ``TourGuide.state``)."""
|
||||
mode = state.get("mode", "hidden")
|
||||
previous_mode = self._state.get("mode")
|
||||
self._state = state
|
||||
self._spot_animation.stop()
|
||||
if mode == "hidden":
|
||||
for card in self._cards.values():
|
||||
card.hide()
|
||||
self.hide()
|
||||
return
|
||||
self.setGeometry(self.parentWidget().rect())
|
||||
for name, card in self._cards.items():
|
||||
if name != mode:
|
||||
card.hide()
|
||||
card = self._cards[mode]
|
||||
# rebuild hidden: widgets added to a visible parent are only shown later, so they would be
|
||||
# missing from the size hint
|
||||
card.hide()
|
||||
card.set_state(state)
|
||||
spot = state.get("spot") or QRect()
|
||||
if mode == "step" and previous_mode == "step" and self._spot.isValid() and spot.isValid():
|
||||
self._spot_animation.setStartValue(QRect(self._spot))
|
||||
self._spot_animation.setEndValue(QRect(spot))
|
||||
self._spot_animation.start()
|
||||
else:
|
||||
self._spot = QRect(spot)
|
||||
self._layout_card(spot if spot.isValid() else None)
|
||||
card.show()
|
||||
self.show()
|
||||
self.raise_()
|
||||
self._update_mask()
|
||||
self.update()
|
||||
|
||||
def _anchor_for_card(self, spot: QRect | None) -> QRect | None:
|
||||
mode = self._state.get("mode")
|
||||
if mode in ("welcome", "hint"):
|
||||
return self._state.get("anchor")
|
||||
if mode in ("hub", "done"):
|
||||
return None
|
||||
if mode == "whatsthis" and spot is None:
|
||||
return None
|
||||
return spot
|
||||
|
||||
def _layout_card(self, spot: QRect | None) -> None:
|
||||
mode = self._state.get("mode")
|
||||
card = self._cards[mode]
|
||||
size = card.preferred_size()
|
||||
bounds = self.rect()
|
||||
if mode == "whatsthis" and spot is None:
|
||||
position, side = QPoint((bounds.width() - size.width()) // 2, 18), "center"
|
||||
else:
|
||||
anchor = self._anchor_for_card(spot)
|
||||
position, side = place_card(anchor, size, bounds)
|
||||
if mode in ("welcome", "hint") and anchor is None:
|
||||
position, side = (
|
||||
QPoint(
|
||||
bounds.right() - size.width() - 18, bounds.bottom() - size.height() - 40
|
||||
),
|
||||
"corner",
|
||||
)
|
||||
self._side = side
|
||||
card.setGeometry(QRect(position, size))
|
||||
|
||||
def _on_spot_animated(self, value) -> None:
|
||||
self._spot = QRect(value)
|
||||
self._update_mask()
|
||||
self.update()
|
||||
|
||||
def _update_mask(self) -> None:
|
||||
mode = self._state.get("mode", "hidden")
|
||||
card = self._cards.get(mode)
|
||||
cards = [card.geometry()] if card is not None else []
|
||||
anchor = self._state.get("anchor")
|
||||
if mode in ("welcome", "hint"):
|
||||
# leave room for the pointer and the ring around the help button
|
||||
cards = [rect.adjusted(-12, -12, 12, 12) for rect in cards]
|
||||
if anchor is not None:
|
||||
cards.append(anchor.adjusted(-8, -8, 8, 8))
|
||||
spot = self._spot if self._spot.isValid() else None
|
||||
self.setMask(interaction_region(mode, self.rect(), spot, cards))
|
||||
|
||||
# -- painting --------------------------------------------------------------
|
||||
def paintEvent(self, _event): # pylint: disable=invalid-name
|
||||
"""Paint the dimming, the spotlight ring, the What's this outlines and the pointer."""
|
||||
mode = self._state.get("mode", "hidden")
|
||||
tokens = ThemeTokens.current()
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
dim = {"step": 0.55, "hub": 0.5, "done": 0.5, "whatsthis": 0.28}.get(mode)
|
||||
spot = self._spot if self._spot.isValid() else None
|
||||
if dim is not None:
|
||||
path = QPainterPath()
|
||||
path.addRect(QRectF(self.rect()))
|
||||
if spot is not None and mode in ("step", "whatsthis"):
|
||||
hole = QPainterPath()
|
||||
hole.addRoundedRect(QRectF(spot), 10, 10)
|
||||
path = path.subtracted(hole)
|
||||
painter.fillPath(path, QColor(0, 0, 0, int(dim * 255)))
|
||||
if mode == "whatsthis":
|
||||
pen = QPen(tokens.primary, 1.5, Qt.PenStyle.DashLine)
|
||||
for index, anchor in enumerate(self._state.get("anchors", [])):
|
||||
if index == self._state.get("selected", -1):
|
||||
continue
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRoundedRect(QRectF(anchor["rect"]).adjusted(1, 1, -1, -1), 6, 6)
|
||||
self._paint_marker(painter, anchor["rect"], tokens)
|
||||
if spot is not None and mode in ("step", "whatsthis"):
|
||||
self._paint_ring(painter, QRectF(spot), tokens)
|
||||
anchor = self._state.get("anchor")
|
||||
if mode in ("welcome", "hint") and anchor is not None:
|
||||
self._paint_ring(painter, QRectF(anchor.adjusted(-4, -4, 4, 4)), tokens)
|
||||
card = self.card
|
||||
if card is not None and self._side in ("right", "left", "below", "above"):
|
||||
self._paint_pointer(painter, card.geometry(), tokens)
|
||||
painter.end()
|
||||
|
||||
def _paint_ring(self, painter: QPainter, rect: QRectF, tokens: ThemeTokens) -> None:
|
||||
glow = QColor(tokens.primary)
|
||||
for width, alpha in ((10, 40), (6, 70)):
|
||||
glow.setAlpha(alpha)
|
||||
painter.setPen(QPen(glow, width))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRoundedRect(rect, 10, 10)
|
||||
painter.setPen(QPen(tokens.primary, 2))
|
||||
painter.drawRoundedRect(rect, 10, 10)
|
||||
|
||||
def _paint_marker(self, painter: QPainter, rect: QRect, tokens: ThemeTokens) -> None:
|
||||
center = QPoint(rect.right() - 2, rect.top() + 2)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(tokens.primary)
|
||||
painter.drawEllipse(center, 7, 7)
|
||||
painter.setPen(tokens.on_primary)
|
||||
font = QFont(self.font())
|
||||
font.setPixelSize(10)
|
||||
font.setBold(True)
|
||||
painter.setFont(font)
|
||||
painter.drawText(
|
||||
QRect(center.x() - 7, center.y() - 7, 14, 14), Qt.AlignmentFlag.AlignCenter, "?"
|
||||
)
|
||||
|
||||
def _paint_pointer(self, painter: QPainter, card: QRect, tokens: ThemeTokens) -> None:
|
||||
mode = self._state.get("mode")
|
||||
target = self._state.get("anchor") if mode in ("welcome", "hint") else self._spot
|
||||
offset = pointer_offset(target, card, self._side)
|
||||
size = 8
|
||||
if self._side == "right":
|
||||
tip, base = QPoint(card.left() - size, card.top() + offset), card.left() + 1
|
||||
points = [tip, QPoint(base, tip.y() - size), QPoint(base, tip.y() + size)]
|
||||
elif self._side == "left":
|
||||
tip, base = QPoint(card.right() + size + 1, card.top() + offset), card.right()
|
||||
points = [tip, QPoint(base, tip.y() - size), QPoint(base, tip.y() + size)]
|
||||
elif self._side == "below":
|
||||
tip, base = QPoint(card.left() + offset, card.top() - size), card.top() + 1
|
||||
points = [tip, QPoint(tip.x() - size, base), QPoint(tip.x() + size, base)]
|
||||
else:
|
||||
tip, base = QPoint(card.left() + offset, card.bottom() + size + 1), card.bottom()
|
||||
points = [tip, QPoint(tip.x() - size, base), QPoint(tip.x() + size, base)]
|
||||
painter.setPen(QPen(tokens.border, 1))
|
||||
painter.setBrush(tokens.card)
|
||||
painter.drawPolygon(QPolygon(points))
|
||||
|
||||
# -- input -----------------------------------------------------------------
|
||||
def mousePressEvent(self, event): # pylint: disable=invalid-name
|
||||
"""Route clicks outside the card: pick an anchor, open the tour list or close."""
|
||||
mode = self._state.get("mode")
|
||||
pos = event.position().toPoint()
|
||||
if mode == "whatsthis":
|
||||
for index, anchor in enumerate(self._state.get("anchors", [])):
|
||||
if anchor["rect"].contains(pos):
|
||||
self.triggered.emit("anchor", str(index))
|
||||
return
|
||||
self.triggered.emit("anchor", "-1")
|
||||
return
|
||||
if mode in ("welcome", "hint"):
|
||||
anchor = self._state.get("anchor")
|
||||
if anchor is not None and anchor.adjusted(-6, -6, 6, 6).contains(pos):
|
||||
self.triggered.emit("hub", "")
|
||||
return
|
||||
if mode in ("hub", "done"):
|
||||
card = self.card
|
||||
if card is None or not card.geometry().contains(pos):
|
||||
self.triggered.emit("close", "")
|
||||
event.accept()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Stop animations."""
|
||||
self._spot_animation.stop()
|
||||
@@ -0,0 +1,489 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Shapes
|
||||
import BecUi
|
||||
|
||||
// Tour guide overlay: dimming with a spotlight, "What's this?" outlines and one card per mode.
|
||||
// Draws backend.state (see TourGuide.state) and reports clicks with backend.trigger(action, arg).
|
||||
Item {
|
||||
id: root
|
||||
required property QtObject backend
|
||||
|
||||
readonly property var st: backend.state
|
||||
readonly property string mode: st.mode || "hidden"
|
||||
readonly property bool hasSpot: !!st.spot && (mode === "step" || mode === "whatsthis")
|
||||
readonly property var anchorRect: (mode === "welcome" || mode === "hint") ? (st.anchor || null) : null
|
||||
readonly property real dim: mode === "step" ? 0.55 : mode === "hub" || mode === "done" ? 0.5
|
||||
: mode === "whatsthis" ? 0.28 : 0
|
||||
|
||||
// Spotlight geometry, animated between steps.
|
||||
property real sx: st.spot ? st.spot.x : 0
|
||||
property real sy: st.spot ? st.spot.y : 0
|
||||
property real sw: st.spot ? st.spot.w : 0
|
||||
property real sh: st.spot ? st.spot.h : 0
|
||||
Behavior on sx { enabled: root.mode === "step"; NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
|
||||
Behavior on sy { enabled: root.mode === "step"; NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
|
||||
Behavior on sw { enabled: root.mode === "step"; NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
|
||||
Behavior on sh { enabled: root.mode === "step"; NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
|
||||
|
||||
function statusTone(status) {
|
||||
return status === "done" ? "success" : status === "in_progress" ? "warning" : "info"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- input behind the card
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.mode !== "hidden"
|
||||
onClicked: {
|
||||
if (root.mode === "whatsthis")
|
||||
root.backend.trigger("anchor", "-1")
|
||||
else if (root.mode === "hub" || root.mode === "done")
|
||||
root.backend.trigger("close", "")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- dimming with a hole
|
||||
Shape {
|
||||
anchors.fill: parent
|
||||
visible: root.dim > 0
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
ShapePath {
|
||||
fillColor: Qt.rgba(0, 0, 0, root.dim)
|
||||
strokeWidth: -1
|
||||
fillRule: ShapePath.OddEvenFill
|
||||
PathRectangle { x: 0; y: 0; width: root.width; height: root.height }
|
||||
PathRectangle {
|
||||
x: root.hasSpot ? root.sx : -10
|
||||
y: root.hasSpot ? root.sy : -10
|
||||
width: root.hasSpot ? root.sw : 1
|
||||
height: root.hasSpot ? root.sh : 1
|
||||
radius: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- "What's this?" outlines
|
||||
Repeater {
|
||||
model: root.mode === "whatsthis" ? (root.st.anchors || []) : []
|
||||
delegate: Item {
|
||||
id: anchorItem
|
||||
required property var modelData
|
||||
required property int index
|
||||
x: modelData.rect.x
|
||||
y: modelData.rect.y
|
||||
width: modelData.rect.w
|
||||
height: modelData.rect.h
|
||||
visible: index !== root.st.selected
|
||||
Shape {
|
||||
anchors.fill: parent
|
||||
ShapePath {
|
||||
strokeColor: theme.primary
|
||||
strokeWidth: 1.5
|
||||
strokeStyle: ShapePath.DashLine
|
||||
dashPattern: [4, 3]
|
||||
fillColor: "transparent"
|
||||
PathRectangle { x: 1; y: 1; width: anchorItem.width - 2; height: anchorItem.height - 2; radius: 6 }
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: 14; height: 14; radius: 7
|
||||
x: parent.width - 9; y: -5
|
||||
color: theme.primary
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "?"
|
||||
color: theme.onPrimary
|
||||
font.pixelSize: 10
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.WhatsThisCursor
|
||||
onClicked: root.backend.trigger("anchor", String(index))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rings
|
||||
component Ring: Item {
|
||||
id: ring
|
||||
property real rx
|
||||
property real ry
|
||||
property real rw
|
||||
property real rh
|
||||
x: rx; y: ry; width: rw; height: rh
|
||||
Rectangle { anchors.fill: parent; anchors.margins: -5; radius: 15; color: "transparent"; border.width: 10; border.color: Qt.alpha(theme.primary, 0.16) }
|
||||
Rectangle { anchors.fill: parent; anchors.margins: -3; radius: 13; color: "transparent"; border.width: 6; border.color: Qt.alpha(theme.primary, 0.28) }
|
||||
Rectangle { anchors.fill: parent; radius: 10; color: "transparent"; border.width: 2; border.color: theme.primary }
|
||||
}
|
||||
Ring {
|
||||
visible: root.hasSpot
|
||||
rx: root.sx; ry: root.sy; rw: root.sw; rh: root.sh
|
||||
}
|
||||
Ring {
|
||||
visible: root.anchorRect !== null
|
||||
rx: root.anchorRect ? root.anchorRect.x - 4 : 0
|
||||
ry: root.anchorRect ? root.anchorRect.y - 4 : 0
|
||||
rw: root.anchorRect ? root.anchorRect.w + 8 : 0
|
||||
rh: root.anchorRect ? root.anchorRect.h + 8 : 0
|
||||
MouseArea { anchors.fill: parent; onClicked: root.backend.trigger("hub", "") }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- card
|
||||
readonly property var cardWidths: ({ step: 340, welcome: 380, hint: 330, hub: 560, done: 380, whatsthis: 320 })
|
||||
property var placement: ({ x: 0, y: 0, side: "center", pointer: 0 })
|
||||
|
||||
function relayout() {
|
||||
if (mode === "hidden" || !loader.item)
|
||||
return
|
||||
placement = backend.place(mode, card.width, card.height)
|
||||
backend.setCardRect(placement.x, placement.y, card.width, card.height)
|
||||
}
|
||||
onStChanged: Qt.callLater(relayout)
|
||||
onWidthChanged: Qt.callLater(relayout)
|
||||
onHeightChanged: Qt.callLater(relayout)
|
||||
|
||||
// pointer towards the spotlight or the help button
|
||||
Rectangle {
|
||||
visible: card.visible && ["right", "left", "below", "above"].indexOf(root.placement.side) >= 0
|
||||
width: 14; height: 14
|
||||
rotation: 45
|
||||
color: theme.card
|
||||
border.color: theme.border
|
||||
x: root.placement.side === "right" ? card.x - 7
|
||||
: root.placement.side === "left" ? card.x + card.width - 7
|
||||
: card.x + root.placement.pointer - 7
|
||||
y: root.placement.side === "below" ? card.y - 7
|
||||
: root.placement.side === "above" ? card.y + card.height - 7
|
||||
: card.y + root.placement.pointer - 7
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
visible: root.mode !== "hidden"
|
||||
x: root.placement.x
|
||||
y: root.placement.y
|
||||
width: root.cardWidths[root.mode] || 340
|
||||
height: (loader.item ? loader.item.implicitHeight : 0) + 32
|
||||
radius: theme.radiusLarge + 2
|
||||
color: theme.card
|
||||
border.color: theme.border
|
||||
onHeightChanged: Qt.callLater(root.relayout)
|
||||
MouseArea { anchors.fill: parent } // swallow clicks on the card background
|
||||
|
||||
Loader {
|
||||
id: loader
|
||||
x: 18; y: 16
|
||||
width: parent.width - 36
|
||||
sourceComponent: root.mode === "step" ? stepCard
|
||||
: root.mode === "welcome" ? welcomeCard
|
||||
: root.mode === "hint" ? hintCard
|
||||
: root.mode === "hub" ? hubCard
|
||||
: root.mode === "done" ? doneCard
|
||||
: root.mode === "whatsthis" ? whatsCard : null
|
||||
onLoaded: Qt.callLater(root.relayout)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- building blocks
|
||||
component Caption: Text {
|
||||
color: theme.primary
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
font.capitalization: Font.AllUppercase
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
component Body: Text {
|
||||
color: theme.fg
|
||||
font.pixelSize: 13
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
component Muted: Text {
|
||||
color: theme.fgMuted
|
||||
font.pixelSize: 12
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
component Title: Text {
|
||||
color: theme.fg
|
||||
font.pixelSize: 16
|
||||
font.weight: Font.DemiBold
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
component IconTile: Rectangle {
|
||||
property string iconName: "explore"
|
||||
property string tone: "primary"
|
||||
property int size: 36
|
||||
implicitWidth: size
|
||||
implicitHeight: size
|
||||
radius: size / 4
|
||||
color: (theme.name, theme.toneTint(tone))
|
||||
Icon { anchors.centerIn: parent; name: parent.iconName; size: parent.size - 14; color: (theme.name, theme.toneText(parent.tone)) }
|
||||
}
|
||||
component Header: RowLayout {
|
||||
property string caption: ""
|
||||
property string closeTip: "Close"
|
||||
spacing: 6
|
||||
Caption { text: parent.caption; Layout.fillWidth: true }
|
||||
IconButton { iconName: "close"; tip: parent.closeTip; compact: true; onClicked: root.backend.trigger("close", "") }
|
||||
}
|
||||
component TourRow: Rectangle {
|
||||
id: row
|
||||
required property var tour
|
||||
property bool detailed: false
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: rowLayout.implicitHeight + 18
|
||||
radius: theme.radiusSmall + 2
|
||||
color: theme.sunken
|
||||
RowLayout {
|
||||
id: rowLayout
|
||||
anchors.fill: parent
|
||||
anchors.margins: 9
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 12
|
||||
IconTile {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
iconName: row.tour.icon
|
||||
size: 34
|
||||
tone: row.tour.status === "done" ? "success" : "primary"
|
||||
}
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Text { text: row.tour.title; color: theme.fg; font.pixelSize: 13; font.weight: Font.DemiBold; Layout.fillWidth: true; wrapMode: Text.WordWrap }
|
||||
Muted { visible: row.detailed; text: row.tour.summary; Layout.fillWidth: true }
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
Muted { text: row.tour.steps + " steps · " + row.tour.minutes + " min"; font.pixelSize: 11 }
|
||||
StatusPill { visible: row.detailed; text: row.tour.label; tone: root.statusTone(row.tour.status); outlined: true }
|
||||
}
|
||||
LinearProgress {
|
||||
visible: row.detailed && row.tour.status === "in_progress"
|
||||
Layout.fillWidth: true
|
||||
value: row.tour.progress
|
||||
tone: "warning"
|
||||
thickness: 4
|
||||
}
|
||||
}
|
||||
TextButton {
|
||||
compact: true
|
||||
text: row.tour.status === "new" ? "Start" : row.tour.status === "in_progress" ? "Resume" : "Replay"
|
||||
variant: row.tour.status === "in_progress" ? "primary" : "neutral"
|
||||
onClicked: root.backend.trigger(row.tour.status === "done" ? "start" : "resume", row.tour.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cards per mode
|
||||
Component {
|
||||
id: stepCard
|
||||
ColumnLayout {
|
||||
spacing: 10
|
||||
Header { caption: root.st.tourTitle || ""; closeTip: "Close tour (resume later)"; Layout.fillWidth: true }
|
||||
Title { text: root.st.title || "" }
|
||||
Body { text: root.st.text || "" }
|
||||
Rectangle {
|
||||
visible: !!root.st.hint
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: hintRow.implicitHeight + 16
|
||||
radius: theme.radiusSmall
|
||||
color: (theme.name, theme.toneTint("primary"))
|
||||
RowLayout {
|
||||
id: hintRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
anchors.leftMargin: 10
|
||||
spacing: 8
|
||||
Icon { name: "touch_app"; color: theme.primary; size: 18; Layout.alignment: Qt.AlignTop }
|
||||
Text { text: "Try it: " + (root.st.hint || ""); color: theme.fg; font.pixelSize: 12; wrapMode: Text.WordWrap; Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
Row {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 2
|
||||
spacing: 4
|
||||
Repeater {
|
||||
model: root.st.count || 0
|
||||
Rectangle {
|
||||
required property int index
|
||||
width: (parent.width - 4 * ((root.st.count || 1) - 1)) / (root.st.count || 1)
|
||||
height: 6
|
||||
radius: 3
|
||||
color: index <= root.st.index ? theme.primary : theme.track
|
||||
Behavior on color { ColorAnimation { duration: 160 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
Muted { text: ((root.st.index || 0) + 1) + " of " + (root.st.count || 1) }
|
||||
Item { Layout.fillWidth: true }
|
||||
TextButton { text: "Back"; variant: "ghost"; compact: true; enabled: (root.st.index || 0) > 0; onClicked: root.backend.trigger("back", "") }
|
||||
TextButton {
|
||||
text: (root.st.index || 0) >= (root.st.count || 1) - 1 ? "Finish" : "Next"
|
||||
variant: "primary"
|
||||
compact: true
|
||||
onClicked: root.backend.trigger("next", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: welcomeCard
|
||||
ColumnLayout {
|
||||
spacing: 10
|
||||
Header { caption: "Welcome to BEC"; closeTip: "Close (show again next time)"; Layout.fillWidth: true }
|
||||
Title { text: "New here? Let us show you around." }
|
||||
Muted { text: "Short tours of a minute or two, one task each. Pick one now, or find them later under Help (F1)."; Layout.fillWidth: true }
|
||||
Repeater {
|
||||
model: (root.st.tours || []).slice(0, 4)
|
||||
TourRow { required property var modelData; tour: modelData }
|
||||
}
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
TextButton { text: "Don't show again"; variant: "ghost"; compact: true; onClicked: root.backend.trigger("never", "") }
|
||||
Item { Layout.fillWidth: true }
|
||||
TextButton { text: "Later"; compact: true; onClicked: root.backend.trigger("close", "") }
|
||||
TextButton {
|
||||
readonly property var first: (root.st.tours || []).filter(t => t.status !== "done")[0]
|
||||
visible: !!first
|
||||
text: "Show me around"
|
||||
variant: "primary"
|
||||
compact: true
|
||||
onClicked: root.backend.trigger("resume", first.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: hintCard
|
||||
ColumnLayout {
|
||||
id: hintRoot
|
||||
readonly property var tour: root.st.tour || ({})
|
||||
spacing: 10
|
||||
RowLayout {
|
||||
spacing: 12
|
||||
IconTile { iconName: hintRoot.tour.icon || "explore"; Layout.alignment: Qt.AlignTop }
|
||||
ColumnLayout {
|
||||
spacing: 2
|
||||
Layout.fillWidth: true
|
||||
Text { text: "New here? " + (hintRoot.tour.title || ""); color: theme.fg; font.pixelSize: 13; font.weight: Font.DemiBold; wrapMode: Text.WordWrap; Layout.fillWidth: true }
|
||||
Muted { text: "A " + (hintRoot.tour.steps || 0) + "-step tour of this view, about " + (hintRoot.tour.minutes || 1) + " min."; Layout.fillWidth: true }
|
||||
}
|
||||
IconButton { iconName: "close"; tip: "Not now"; compact: true; Layout.alignment: Qt.AlignTop; onClicked: root.backend.trigger("close", "") }
|
||||
}
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
Item { Layout.fillWidth: true }
|
||||
TextButton { text: "Not now"; variant: "ghost"; compact: true; onClicked: root.backend.trigger("close", "") }
|
||||
TextButton { text: "Start tour"; variant: "primary"; compact: true; onClicked: root.backend.trigger("start", (root.st.tour || {}).id || "") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: hubCard
|
||||
ColumnLayout {
|
||||
spacing: 10
|
||||
RowLayout {
|
||||
spacing: 12
|
||||
IconTile { iconName: "school"; size: 40; Layout.alignment: Qt.AlignTop }
|
||||
ColumnLayout {
|
||||
spacing: 2
|
||||
Layout.fillWidth: true
|
||||
Text { text: "Tours"; color: theme.fg; font.pixelSize: 20; font.weight: Font.DemiBold }
|
||||
Muted { text: "Short guided tours, one task each. Your progress is saved."; Layout.fillWidth: true }
|
||||
}
|
||||
IconButton { iconName: "close"; tip: "Close"; compact: true; Layout.alignment: Qt.AlignTop; onClicked: root.backend.trigger("close", "") }
|
||||
}
|
||||
RowLayout {
|
||||
spacing: 10
|
||||
LinearProgress { Layout.fillWidth: true; tone: "success"; value: (root.st.done || 0) / Math.max(1, root.st.total || 1) }
|
||||
Muted { text: (root.st.done || 0) + " of " + (root.st.total || 0) + " done" }
|
||||
}
|
||||
Repeater {
|
||||
model: root.st.tours || []
|
||||
TourRow { required property var modelData; tour: modelData; detailed: true }
|
||||
}
|
||||
Divider {}
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
TextButton { text: "What's this? Shift+F1"; iconName: "help_center"; compact: true; onClicked: root.backend.trigger("whatsthis", "") }
|
||||
Item { Layout.fillWidth: true }
|
||||
TextButton { text: "Reset progress"; variant: "ghost"; compact: true; onClicked: root.backend.trigger("reset", "") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: doneCard
|
||||
ColumnLayout {
|
||||
spacing: 10
|
||||
RowLayout {
|
||||
spacing: 12
|
||||
IconTile { iconName: "check_circle"; tone: "success"; size: 40; Layout.alignment: Qt.AlignTop }
|
||||
ColumnLayout {
|
||||
spacing: 2
|
||||
Layout.fillWidth: true
|
||||
Title { text: "Tour complete" }
|
||||
Muted { text: "You finished “" + (root.st.finished || "") + "”."; Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
Divider { visible: !!root.st.next }
|
||||
Muted { visible: !!root.st.next; text: "UP NEXT"; font.pixelSize: 11 }
|
||||
Text {
|
||||
visible: !!root.st.next
|
||||
text: root.st.next ? root.st.next.title + " · " + root.st.next.steps + " steps" : ""
|
||||
color: theme.fg; font.pixelSize: 13; font.weight: Font.Medium
|
||||
}
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
TextButton { text: "All tours"; variant: "ghost"; compact: true; onClicked: root.backend.trigger("hub", "") }
|
||||
Item { Layout.fillWidth: true }
|
||||
TextButton { text: "Close"; compact: true; onClicked: root.backend.trigger("close", "") }
|
||||
TextButton {
|
||||
visible: !!root.st.next
|
||||
text: "Start next"; variant: "primary"; compact: true
|
||||
onClicked: root.backend.trigger("resume", root.st.next.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: whatsCard
|
||||
ColumnLayout {
|
||||
id: whatsRoot
|
||||
readonly property var anchors_: root.st.anchors || []
|
||||
readonly property int selected: root.st.selected === undefined ? -1 : root.st.selected
|
||||
readonly property var current: selected >= 0 && selected < anchors_.length ? anchors_[selected] : null
|
||||
spacing: 10
|
||||
RowLayout {
|
||||
visible: !whatsRoot.current
|
||||
spacing: 10
|
||||
Icon { name: "help_center"; color: theme.primary; size: 20 }
|
||||
Body { text: "What's this? Click any outlined control (" + whatsRoot.anchors_.length + " on screen)." }
|
||||
TextButton { text: "Done"; compact: true; onClicked: root.backend.trigger("close", "") }
|
||||
}
|
||||
Header { visible: !!whatsRoot.current; caption: "What's this?"; closeTip: "Leave What's this"; Layout.fillWidth: true }
|
||||
Text {
|
||||
visible: !!whatsRoot.current
|
||||
text: whatsRoot.current ? whatsRoot.current.title : ""
|
||||
color: theme.fg; font.pixelSize: 15; font.weight: Font.DemiBold
|
||||
Layout.fillWidth: true; wrapMode: Text.WordWrap
|
||||
}
|
||||
Body { visible: !!whatsRoot.current; text: whatsRoot.current ? whatsRoot.current.text : "" }
|
||||
RowLayout {
|
||||
visible: !!whatsRoot.current && !!whatsRoot.current.tour_id
|
||||
spacing: 8
|
||||
Muted { text: whatsRoot.current ? "Part of “" + whatsRoot.current.tour_title + "”" : ""; Layout.fillWidth: true }
|
||||
TextButton { text: "Take the tour"; variant: "primary"; compact: true; onClicked: root.backend.trigger("resume", whatsRoot.current.tour_id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user