"""Per-module walkthroughs — the coach-marks, once per module, on demand.
:mod:`spacr.qt.first_run` shows one tour, once, about the home screen. That
answered "where am I?" and nothing else: a user who has seen it still opens
Mask for the first time and meets 190 settings under thirteen headings with
no idea which two of them matter, and there is no way to ask again.
This module makes the tour per module and repeatable:
* the first time a module is opened, its own short walkthrough runs;
* every walkthrough is available afterwards from **Help → Walkthroughs**,
and from the command palette, for any module — not only the one on
screen;
* "seen" is tracked per module, so a new module added to the shell next
release introduces itself to an existing user rather than staying silent
because the one global flag was set in 2026.
A walkthrough is built from the module's own settings layout rather than
hand-written per module. That is what stops it rotting: the steps name the
module's first curated group and its essential settings, both of which come
from :mod:`spacr.qt.screens.settings_model`, so a layout change updates the
walkthrough with it. Modules that want more can register extra steps::
from spacr.qt.walkthrough import register_steps
register_steps("mask", [WalkStep("Test mode first", "...")])
Reuses :class:`spacr.qt.first_run._TourOverlay` for the rendering, because a
second dimmed-overlay-with-a-card would be a second thing to keep looking
like the first one.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional
from PySide6.QtCore import QObject, Qt
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QMainWindow, QMenu, QWidget
from .first_run import TourStep, _TourOverlay, find_menu
from .i18n import tr
from .prefs import _store_args
LOG = logging.getLogger("spacr.qt.walkthrough")
_ORG = "spacr"
_APP = "qt"
_KEY_SEEN = "onboarding/module_walkthrough_seen"
#: The Help submenu label. Kept verbatim — ``spacr/qt/i18n.py`` keys its
#: catalog on the English string.
MENU_TITLE = "Walkthroughs"
def _settings():
"""Open spaCR's ``QSettings``.
Imported inside the call so this module can be read without Qt.
:returns: the settings store.
"""
from PySide6.QtCore import QSettings
return QSettings(*_store_args(_ORG, _APP))
[docs]
def was_seen(app_key: str) -> bool:
"""True once ``app_key``'s walkthrough has been finished or dismissed.
:param app_key: the module's registry key; its seen flag is read from
spaCR's ``QSettings``.
"""
raw = _settings().value(f"{_KEY_SEEN}/{app_key}", False)
if isinstance(raw, bool):
return raw
return str(raw).lower() in ("true", "1", "yes")
[docs]
def mark_seen(app_key: str) -> None:
"""Remember that ``app_key``'s walkthrough has been shown.
:param app_key: the module's registry key; its seen flag is set in
spaCR's ``QSettings``.
"""
_settings().setValue(f"{_KEY_SEEN}/{app_key}", True)
[docs]
def reset(app_key: Optional[str] = None) -> None:
"""Forget one module's walkthrough, or every module's.
:param app_key: the module to reset, or ``None`` for all of them.
"""
store = _settings()
if app_key is None:
store.remove(_KEY_SEEN)
else:
store.remove(f"{_KEY_SEEN}/{app_key}")
@dataclass
[docs]
class WalkStep:
"""One narrated coach-mark in a module walkthrough.
:ivar title: short headline.
:ivar body: one or two sentences under it.
:ivar highlight: callable taking the live screen and returning the
widget to ring, or ``None`` to centre the card.
"""
title: str
body: str
highlight: Optional[Callable[[QWidget], Optional[QWidget]]] = None
#: Module-specific extra steps, appended after the derived ones.
_EXTRA_STEPS: Dict[str, List[WalkStep]] = {}
[docs]
def register_steps(app_key: str, steps: List[WalkStep],
*, replace: bool = False) -> None:
"""Add module-specific steps to ``app_key``'s walkthrough.
The seam a module uses to say something the layout cannot — "run test
mode on three fields before committing a plate" is advice, not
structure, and no amount of reading the settings map produces it.
:param app_key: the module's app key.
:param steps: steps appended after the derived ones.
:param replace: overwrite an existing registration instead of raising.
:raises ValueError: on a second registration without ``replace``.
"""
key = str(app_key)
if key in _EXTRA_STEPS and not replace:
raise ValueError(
f"walkthrough steps for {key!r} are already registered; pass "
"replace=True if that is really what you mean")
_EXTRA_STEPS[key] = list(steps)
[docs]
def unregister_steps(app_key: str) -> bool:
"""Drop a module's registered steps. ``True`` if there were any.
:param app_key: the module's registry key, as passed to
:func:`register_steps`.
"""
return _EXTRA_STEPS.pop(str(app_key), None) is not None
def _module_name(app_key: str) -> str:
"""Return a module's display name.
:param app_key: the registry key.
:returns: the name the registry gives it, falling back to the key --
a walkthrough that says ``map_barcodes`` is worse than one that says
nothing, but better than one that fails to build.
"""
try:
from .app import APPS
for row in APPS:
if row[0] == app_key:
return str(row[1])
except Exception:
pass
return app_key
def _sentence(names: List[str]) -> str:
"""Join names into readable English.
:param names: the names to join.
:returns: ``""``, one name, or a comma list ending in "and" -- so a
sentence about three modules reads as prose rather than as a list.
"""
if not names:
return ""
if len(names) == 1:
return names[0]
return ", ".join(names[:-1]) + " and " + names[-1]
[docs]
def build_steps(app_key: str) -> List[WalkStep]:
"""The walkthrough for one module, derived from its settings layout.
Four beats, in the order somebody actually works: what the module is,
where its inputs go, which settings matter out of how many, and how to
run it. Every fact in them is read from the registry or the layout, so
none of them can go stale the way a hand-written paragraph does.
:param app_key: the module's app key.
"""
if app_key.startswith("pathway:"):
return _pathway_steps(app_key.partition(":")[2])
name = _module_name(app_key)
steps: List[WalkStep] = []
intro = ""
try:
from .app import APPS
for row in APPS:
if row[0] == app_key:
intro = str(row[2])
break
except Exception:
intro = ""
steps.append(WalkStep(
title=name,
body=(intro or f"This is the {name} module.")
+ " Press Esc at any time to close this walkthrough; you can "
"reopen it from Help → Walkthroughs.",
highlight=None,
))
groups: List[str] = []
essentials: List[str] = []
total = 0
try:
from .screens.settings_model import (
categories_for_app, essential_keys, get_categories,
resolve_default_settings,
)
cats = categories_for_app(app_key, get_categories())
defaults = resolve_default_settings(app_key)
groups = [name for name, keys in cats.items()
if any(k in defaults for k in keys)]
essentials = [k for k in essential_keys(app_key, cats)
if k in defaults]
total = len(defaults)
except Exception:
LOG.debug("could not read the layout for %r", app_key, exc_info=True)
if groups:
steps.append(WalkStep(
title="Start at the top",
body=(f"The settings are grouped into {len(groups)}: "
f"{_sentence(groups[:4])}"
+ (", and more." if len(groups) > 4 else ".")
+ f" “{groups[0]}” is where you point the module at your "
"data; everything below it assumes that is right."),
highlight=_first_section,
))
if essentials and total:
steps.append(WalkStep(
title=f"{len(essentials)} settings, not {total}",
body=(f"The strip above the form opens on Essentials — the "
f"{len(essentials)} settings this module cannot run "
f"without. Switch it to All settings for the other "
f"{total - len(essentials)}, or type in the search box to "
"find one by name or by what its description says it "
"does."),
highlight=_search_bar,
))
steps.append(WalkStep(
title="Run it",
body=("Run starts the module and streams its log into the console "
"below. Once a set of settings works, save it as a recipe so "
"the next plate is one click instead of a form."),
highlight=_run_button,
))
steps.extend(_EXTRA_STEPS.get(str(app_key), []))
return steps
def _first_section(screen: QWidget) -> Optional[QWidget]:
"""Find the settings category a walkthrough should point at.
:param screen: the module screen.
:returns: the first VISIBLE section, falling back to the first of any --
pointing at a section the user cannot see would highlight nothing.
``None`` when the screen has no sections.
"""
sections = getattr(screen, "_settings_sections", None) or []
for section in sections:
if section.isVisible():
return section
return sections[0] if sections else None
def _search_bar(screen: QWidget) -> Optional[QWidget]:
"""Find a screen's settings search strip.
:param screen: the module screen.
:returns: the strip, or ``None`` when the screen has none.
"""
return getattr(screen, "_settings_search", None)
def _run_button(screen: QWidget) -> Optional[QWidget]:
"""Find a screen's Run button.
The known attribute names are tried first and a search by label second,
because screens that build themselves rather than being the generic
``AppScreen`` name the button whatever suited them.
:param screen: the module screen.
:returns: the button, or ``None`` when the screen has none to point at.
"""
for attr in ("_btn_run", "_run_btn", "_btn_start"):
widget = getattr(screen, attr, None)
if widget is not None:
return widget
from PySide6.QtWidgets import QPushButton
for button in screen.findChildren(QPushButton):
if button.text().strip().lower() == "run":
return button
return None
def _already_current(window, app_key: str) -> bool:
"""Whether ``app_key``'s screen is the one the window is already showing.
``QStackedWidget.currentChanged`` fires AFTER the screen has been made
current, and the automatic walkthrough listens to it -- so navigating
unconditionally re-entered ``_on_nav_selected`` for the module already on
screen. That second trip ran BEFORE the outer call had installed its
readiness watcher, so the outer call then replaced the nested probe.
FAILS TOWARD NAVIGATING. A window that cannot say what it is showing gets
navigated to, which costs one redundant trip; skipping on a bad reading
would leave the walkthrough highlighting a screen nobody is looking at,
which is the more expensive mistake.
:param window: the live main window.
:param app_key: the module about to be walked through.
:returns: True only when the stack says that module is current.
"""
try:
current = window._stack.currentWidget()
except Exception: # noqa: BLE001
LOG.debug("could not read the current screen; navigating anyway",
exc_info=True)
return False
return current is not None and getattr(current, "app_key", None) == app_key
[docs]
def show_walkthrough(window: QMainWindow, app_key: str,
*, force: bool = True) -> Optional[_TourOverlay]:
"""Run ``app_key``'s walkthrough over ``window``.
Navigates to the module first — a walkthrough that highlights a settings
group on a screen the user is not looking at highlights nothing.
:param window: the live main window.
:param app_key: the module to walk through.
:param force: show even when it has been seen before. The default, since
every route to this function except the automatic one is somebody
asking for it.
:returns: the overlay, or ``None`` when it was skipped.
"""
if app_key.startswith("pathway:"):
return _show_pathway(window, app_key.partition(":")[2])
if not force and was_seen(app_key):
return None
screen = None
try:
nav = getattr(window, "_on_nav_selected", None)
if callable(nav) and not _already_current(window, app_key):
nav(app_key)
screen = window._screens.get(app_key)
except Exception:
LOG.debug("could not open %r for its walkthrough", app_key,
exc_info=True)
steps = build_steps(app_key)
if not steps:
return None
target = screen if screen is not None else window
tour = [
TourStep(
title=step.title,
body=step.body,
highlight=_bind_highlight(step.highlight, target),
)
for step in steps
]
overlay = _TourOverlay(window, tour, on_finish=_Seen(app_key).mark)
overlay.show()
overlay.raise_()
overlay.setFocus()
return overlay
def _workflow_map():
"""Read the bundled, source-checked module and pathway contracts."""
import json
from importlib.resources import files
return json.loads((files("spacr") / "resources" / "module_workflows.json")
.read_text(encoding="utf-8"))
def _pathway_steps(key):
"""Use exactly the actions shared with the API and tutorial source map."""
data = _workflow_map()
route = data["pathways"][key]
first = data["modules"][route["home_app"]]["name"]
steps = [WalkStep(tr("Home"), tr(
"Start on Home with {module}. Next shows the route; it does not run the analysis. "
"Close the walkthrough whenever you are ready to work.", module=tr(first)))]
steps.extend(WalkStep(tr(data["modules"][step["module"]]["name"]),
tr(step["action"])) for step in route["steps"])
if route.get("note"):
steps.append(WalkStep(tr(route["title"]), tr(route["note"])))
return steps
class _PathwayOverlay(_TourOverlay):
"""A guided route that opens screens without loading data or running jobs."""
def __init__(self, window, key, data):
"""Keep the same pathway record used to build the displayed steps."""
self._route = data["pathways"][key]
self._modules = data["modules"]
super().__init__(window, [TourStep(step.title, step.body)
for step in _pathway_steps(key)],
on_finish=self._release, translated=True)
def _release(self):
"""Restore automatic module tours when this route ends."""
self._window._pathway_walkthrough_active = False
def _next(self):
"""Open the next module's host, leaving nested actions for the user."""
next_index = self._idx
if next_index < len(self._route["steps"]):
module = self._modules[self._route["steps"][next_index]["module"]]
target = module["parent"] or module["home"]
if target:
self._window._on_nav_selected(target)
super()._next()
if self._idx < len(self._steps):
self.raise_()
self.setFocus()
def _show_pathway(window, key):
"""Begin a mapped pathway at Home and suppress overlapping module tours."""
data = _workflow_map()
if key not in data["pathways"]:
raise KeyError(key)
for previous in window.findChildren(_TourOverlay):
try:
previous._finish()
except RuntimeError:
pass
window._pathway_walkthrough_active = True
try:
window._on_nav_selected("__home__")
overlay = _PathwayOverlay(window, key, data)
except Exception:
window._pathway_walkthrough_active = False
raise
window._pathway_overlay = overlay
overlay.show()
overlay.raise_()
overlay.setFocus()
return overlay
class _Seen:
"""Bound-method callback marking one module's walkthrough seen.
A tiny object rather than a lambda so the overlay's finish callback is a
bound method — a closure over ``app_key`` would keep whatever else was
in that frame alive for as long as the overlay lived.
:param app_key: which module to mark. THE ONLY STATE THIS HOLDS, which
is the point -- a closure over it would keep the rest of the frame
alive for as long as the overlay.
"""
def __init__(self, app_key: str):
"""Hold the module key and nothing else."""
self._app_key = app_key
def mark(self) -> None:
"""Record that this module's walkthrough has been shown."""
mark_seen(self._app_key)
class _Highlight:
"""Bound-method adapter from a step's screen-taking highlight to the
window-taking one :class:`spacr.qt.first_run.TourStep` expects.
:param fn: the step's highlight, which takes a screen.
:param target: the screen to hand it. Bound HERE rather than resolved
when called, so the step highlights the screen the tour was built
for even if another is on top by the time it runs.
"""
def __init__(self, fn, target):
"""Bind the step's highlight to the screen it was built for."""
self._fn = fn
self._target = target
def __call__(self, _window):
"""Highlight the bound screen, swallowing any failure.
A decoration that raises would stop the tour, and a tour that cannot get
past a step is worse than one that misses a highlight.
"""
try:
return self._fn(self._target)
except Exception:
return None
def _bind_highlight(fn, target):
"""Bind a highlight resolver to its target.
:param fn: the resolver, or ``None``.
:param target: what to resolve against.
:returns: the bound highlight, or ``None`` when there is no resolver --
so a step without one is a step that highlights nothing rather than
one that raises.
"""
if fn is None:
return None
return _Highlight(fn, target)
[docs]
def maybe_show(window: QMainWindow, app_key: str) -> Optional[_TourOverlay]:
"""Show ``app_key``'s walkthrough if this user has not seen it.
Called when a module is opened. Per module, so a module added next
release introduces itself instead of being silenced by a flag set the
first time the app ever ran.
:param window: the main window the walkthrough is shown over.
:param app_key: the module's registry key; nothing is shown when
:func:`was_seen` is already true for it.
"""
if was_seen(app_key):
return None
return show_walkthrough(window, app_key, force=True)
_OFFER_AFTER_MS = 30
class _WalkthroughHandler(QObject):
"""Bound-method targets for the menu entries and the screen stack."""
def __init__(self, window: QMainWindow):
"""Watch a window's stack for the first visit to each module.
:param window: the main window. ALSO THE QOBJECT PARENT, so the
handler cannot outlive the window whose stack it reads -- a
``currentChanged`` arriving after teardown would otherwise reach
a handler holding a deleted stack.
"""
super().__init__(window)
self._window = window
def on_current_changed(self, _index: int) -> None:
"""Offer the walkthrough the first time a module is opened."""
if getattr(self._window, "_pathway_walkthrough_active", False):
return
try:
screen = self._window._stack.currentWidget()
app_key = str(getattr(screen, "app_key", "") or "")
except Exception:
return
if not app_key or was_seen(app_key):
return
if getattr(screen, "_settings_model", None) is None:
return
from PySide6.QtCore import QTimer
QTimer.singleShot(_OFFER_AFTER_MS, self, lambda: self._offer(app_key))
def _offer(self, app_key: str) -> None:
"""Show ``app_key``'s walkthrough if its screen is still the one on show.
RUN A MOMENT AFTER THE SWITCH, NOT INSIDE IT. ``currentChanged`` is
emitted from inside the stack's switch, which is also where the new
screen is first shown and styled; building the overlay there added
its cost to that one freeze (80-150 ms of a first Mask or Analyze
Plaques open, measured). A timer lets the event loop turn and the
screen paint first. Everything the switch checked is checked again,
because the user may have moved on in between. The wait,
:data:`_OFFER_AFTER_MS`, is long enough for the loop to turn and
short enough to read as part of the opening.
"""
if getattr(self._window, "_pathway_walkthrough_active", False):
return
try:
screen = self._window._stack.currentWidget()
if str(getattr(screen, "app_key", "") or "") != app_key:
return
except Exception:
return
if was_seen(app_key):
return
maybe_show(self._window, app_key)
def on_reset(self, _checked: bool = False) -> None:
"""Clear every module's seen flag so the walkthroughs run again."""
reset()
class _MenuTrigger(QObject):
"""One module's Help-menu entry."""
def __init__(self, window: QMainWindow, app_key: str):
"""Bind one Help-menu entry to one module's walkthrough.
:param window: the main window the walkthrough is shown over; also
the QObject parent.
:param app_key: which module's walkthrough this entry runs. Fixed at
construction, so the entry runs ITS module rather than whatever
happens to be on screen when it is chosen.
"""
super().__init__(window)
self._window = window
self._app_key = app_key
def on_triggered(self, _checked: bool = False) -> None:
"""Run this entry's walkthrough."""
show_walkthrough(self._window, self._app_key, force=True)
[docs]
def install_window_hooks(window: QMainWindow) -> Optional[_WalkthroughHandler]:
"""Wire the walkthroughs into a live main window.
Called once from :func:`spacr.qt.shortcuts.install`.
:param window: the main window; gets the Help-menu submenu, and its
``_stack`` screen stack (when present) is followed so each module's
walkthrough is offered on the first visit. Wiring twice is a no-op.
"""
install_help_menu(window)
handler = getattr(window, "_walkthrough_handler", None)
if handler is None:
handler = _WalkthroughHandler(window)
window._walkthrough_handler = handler
stack = getattr(window, "_stack", None)
if stack is None or getattr(window, "_walkthrough_wired", False):
return handler
try:
stack.currentChanged.connect(handler.on_current_changed)
except Exception:
LOG.debug("could not follow the screen stack", exc_info=True)
return handler
window._walkthrough_wired = True
return handler