"""
First-launch tour — one-time coach-marks over the home screen.
Fires the first time ``spacr`` boots (state stored in QSettings). A
translucent full-window overlay dims the app; a numbered card walks
the user through: sidebar → test data → home tiles → hint bar. The
user can dismiss at any point via Skip / Esc; the "seen" flag is
saved on skip OR after the last step so the tour never fires twice
unless they hit "Reset" in Preferences.
Public API::
from spacr.qt.first_run import (
maybe_show_tour, was_tour_shown, mark_tour_seen,
reset_tour_state,
)
# In MainWindow.__init__ after everything is built:
maybe_show_tour(self)
# From a Preferences reset button:
reset_tour_state()
The tour is deliberately spartan — five steps, ~90 seconds tops.
Users who don't want it hit Esc and never see it again.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Callable, List, Optional
from PySide6.QtCore import QEvent, QPoint, QRect, Qt
from PySide6.QtGui import QColor, QKeyEvent, QPainter, QPainterPath, QPen
from .prefs import _store_args
from PySide6.QtWidgets import (
QLabel, QMainWindow, QPushButton, QScrollArea, QVBoxLayout, QWidget,
)
LOG = logging.getLogger("spacr.qt.first_run")
_ORG = "spacr"
_APP = "qt"
_KEY_TOUR_SEEN = "onboarding/first_run_tour_seen"
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_tour_shown() -> bool:
"""Return True iff the user has completed or dismissed the tour."""
raw = _settings().value(_KEY_TOUR_SEEN, False)
if isinstance(raw, bool):
return raw
return str(raw).lower() in ("true", "1", "yes")
[docs]
def mark_tour_seen() -> None:
"""Persist the "seen" flag so the tour doesn't fire on future boots."""
_settings().setValue(_KEY_TOUR_SEEN, True)
[docs]
def reset_tour_state() -> None:
"""Clear the "seen" flag — next launch shows the tour again."""
_settings().remove(_KEY_TOUR_SEEN)
@dataclass
[docs]
class TourStep:
"""One narrated coach-mark.
:ivar title: short headline shown on the card.
:ivar body: 1-2 sentences under the title.
:ivar highlight: callable returning the widget to highlight, or
None to centre the card without a highlight box.
"""
title: str
body: str
highlight: Optional[Callable[[QMainWindow], Optional[QWidget]]] = None
def _section_names_sentence() -> str:
"""List the real home-page sections, read from the app registry.
Hard-coding them here is how this line came to advertise "Core,
Analysis, Cellpose and Sequencing" long after those sections stopped
existing. Reading the registry keeps the tour honest the next time
the grouping changes.
It walks :data:`spacr.qt.app.APPS` in APPS order rather than
:data:`spacr.qt.app.SECTIONS`, because the headings the sidebar
*draws* are the ones its rows produce — naming a section with no
rows under it would send the reader looking for a heading that is
not there.
"""
try:
from .app import APPS
names = list(dict.fromkeys(str(row[3]) for row in APPS))
except Exception:
names = []
if not names:
return (
"Primary modules are grouped here by purpose; related workflows "
"are reached from their host module."
)
if len(names) == 1:
listed = names[0]
else:
listed = ", ".join(names[:-1]) + " and " + names[-1]
return (
f"Primary modules are grouped here into {listed}; related workflows "
"are reached from their host module."
)
DEFAULT_TOUR: List[TourStep] = [
TourStep(
title="Welcome to spaCR",
body="This quick 5-step tour will show you the home layout. "
"Press Esc at any time to skip.",
highlight=None,
),
TourStep(
title="Sidebar — apps by category",
body=_section_names_sentence()
+ " Click any name to open it. Ctrl+1 through Ctrl+9 opens "
"the first nine "
"apps in sidebar order.",
highlight=lambda w: getattr(w, "_sidebar", None),
),
TourStep(
title="Test data and walkthroughs",
body="Use Load test data in a module to load its example dataset "
"and settings. Pipeline overviews on Home explains the inputs "
"and outputs and opens the matching walkthrough.",
highlight=None,
),
TourStep(
title="Drag & drop",
body="Drop a folder of acquisition images onto Mask to set its "
"input; Mask detects the filename regex and displays a metadata "
"validation summary in the Console. Measure, Annotate and other modules "
"accept the files or folders described by their input controls.",
highlight=None,
),
TourStep(
title="Command palette",
body="Ctrl+K opens a searchable list of every app, every "
"recent run, and every menu action. Ctrl+P opens "
"Preferences. F1 shows the shortcut cheat sheet.",
highlight=None,
),
]
#: Retained under the old private name for anything that imported it.
_find_menu = find_menu
class _TourOverlay(QWidget):
"""Translucent overlay + step card. Owns the tour lifecycle."""
def __init__(self, window: QMainWindow, steps: List[TourStep],
on_finish: Optional[Callable[[], None]] = None,
*, translated: bool = False):
"""
:param window: the main window the overlay covers.
:param steps: the narrated coach-marks, in order.
:param on_finish: called once when the tour is finished or skipped,
instead of marking the app-wide first-run flag. This is what
lets :mod:`spacr.qt.walkthrough` reuse the overlay for a
per-module tour without its own copy of the rendering — a second
dimmed card would be a second thing to keep looking like this
one.
:param translated: the caller already translated and formatted the
step text. Preserve it instead of translating it a second time.
"""
super().__init__(window)
self._window = window
self._steps = steps
self._idx = 0
self._on_finish = on_finish
self.setAttribute(Qt.WA_TransparentForMouseEvents, False)
self.setGeometry(window.rect())
from .theme import mark_as_a_sheet_target
self.setObjectName("TourOverlay")
self.setStyleSheet("QWidget#TourOverlay { background: transparent; }")
mark_as_a_sheet_target(self)
self.raise_()
self._card = QWidget(self)
self._card.setObjectName("TourCard")
self._card.setStyleSheet(
"QWidget#TourCard {"
" background: #0d0e10;"
" border: 1px solid #4A9EFF;"
" border-radius: 10px;"
" padding: 20px;"
"}"
)
col = QVBoxLayout(self._card)
col.setContentsMargins(20, 20, 20, 20)
col.setSpacing(8)
from .i18n import tr
self._step_text = str if translated else tr
self._step_lbl = QLabel(tr("Step {n} / {total}", n=1,
total=len(steps)))
self._step_lbl.setObjectName("TourStep")
self._step_lbl.setStyleSheet("color: #4A9EFF;")
col.addWidget(self._step_lbl)
self._title_lbl = QLabel(self._step_text(steps[0].title))
self._title_lbl.setProperty("i18nSkipText", translated)
self._title_lbl.setObjectName("TourTitle")
self._title_lbl.setStyleSheet("color: #e5e5e5;")
self._title_lbl.setWordWrap(True)
content = QWidget()
text_layout = QVBoxLayout(content)
text_layout.setContentsMargins(0, 0, 0, 0)
text_layout.addWidget(self._title_lbl)
self._body_lbl = QLabel(self._step_text(steps[0].body))
self._body_lbl.setProperty("i18nSkipText", translated)
self._body_lbl.setObjectName("TourBody")
self._body_lbl.setWordWrap(True)
self._body_lbl.setStyleSheet("color: #a1a6ad;")
text_layout.addWidget(self._body_lbl)
self._text_scroll = QScrollArea()
self._text_scroll.setFrameShape(QScrollArea.NoFrame)
self._text_scroll.setWidgetResizable(True)
self._text_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._text_scroll.setWidget(content)
self._text_scroll.setMinimumSize(0, 0)
col.addWidget(self._text_scroll, 1)
btn_row = QWidget()
from PySide6.QtWidgets import QHBoxLayout
row = QHBoxLayout(btn_row)
row.setContentsMargins(0, 8, 0, 0)
row.setSpacing(8)
self._skip_btn = QPushButton(tr("Skip"))
self._skip_btn.setStyleSheet(_ghost_btn_qss())
self._skip_btn.clicked.connect(self._skip)
row.addWidget(self._skip_btn)
row.addStretch(1)
self._next_btn = QPushButton(tr("Next"))
self._next_btn.setStyleSheet(_primary_btn_qss())
self._next_btn.clicked.connect(self._next)
row.addWidget(self._next_btn)
col.addWidget(btn_row)
self._update_card_position()
self._card.show()
window.installEventFilter(self)
def paintEvent(self, event) -> None:
"""Dim the window and cut a lit ring around this step's target.
The highlighted rectangle is excluded from the painted shade. Clearing
the shared backing store can punch a transparent hole through the main
window on macOS, so the target's existing pixels must remain intact.
Missing targets leave the dimming intact.
:param event: the paint event.
"""
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
shade = QPainterPath()
shade.addRect(self.rect())
rect = None
highlight_fn = self._steps[self._idx].highlight
if highlight_fn is not None:
try:
target = highlight_fn(self._window)
if target is not None:
rect = _widget_rect_in_window(target, self._window)
if rect is not None:
hole = QPainterPath()
hole.addRect(rect)
shade = shade.subtracted(hole)
except Exception:
pass
p.fillPath(shade, QColor(0, 0, 0, 170))
if rect is not None:
p.setBrush(Qt.NoBrush)
p.setPen(QPen(QColor("#4A9EFF"), 3))
p.drawRoundedRect(rect.adjusted(-4, -4, 4, 4), 6, 6)
p.end()
def resizeEvent(self, event) -> None:
"""Keep the caption card in place when the overlay resizes.
:param event: the resize event.
"""
self._update_card_position()
def _update_card_position(self) -> None:
"""Fit text and actions within the visible part of the application."""
if not hasattr(self, '_text_scroll'):
return
from .hidpi import screen_for_widget
from .preferences import scaled_px
available = screen_for_widget(self).availableGeometry()
visible = self.rect().intersected(QRect(
self.mapFromGlobal(available.topLeft()), available.size()))
area = visible.adjusted(12, 12, -12, -12)
if area.isEmpty():
return
width = min(scaled_px(480), area.width())
text_width = max(1, width - 60)
layout = self._text_scroll.widget().layout()
text_height = layout.totalHeightForWidth(text_width)
footer = self._next_btn.sizeHint().height()
height = min(area.height(), max(160, text_height + footer + 90))
self._card.setGeometry(area.center().x() - width // 2,
area.bottom() - height + 1, width, height)
def eventFilter(self, obj, event):
"""Follow the window's size, so the overlay always covers it.
:param obj: the object the event is for.
:param event: the event.
:returns: whatever the base filter returns -- the resize is observed,
never consumed.
"""
window = getattr(self, "_window", None)
if window is not None and obj is window \
and event.type() in (QEvent.Resize, QEvent.Move):
self.setGeometry(window.rect())
self._update_card_position()
return super().eventFilter(obj, event)
def keyPressEvent(self, event: QKeyEvent) -> None:
"""Take Escape to skip the tour and Return to advance it.
:param event: the key event.
"""
if event.key() == Qt.Key_Escape:
self._skip()
return
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
self._next()
return
super().keyPressEvent(event)
def _next(self) -> None:
"""Advance one step, finishing when the last one is past."""
self._idx += 1
if self._idx >= len(self._steps):
self._finish()
return
from .i18n import tr
step = self._steps[self._idx]
self._step_lbl.setText(tr("Step {n} / {total}", n=self._idx + 1,
total=len(self._steps)))
self._title_lbl.setText(self._step_text(step.title))
self._body_lbl.setText(self._step_text(step.body))
if self._idx == len(self._steps) - 1:
self._next_btn.setText(tr("Finish"))
self._update_card_position()
self.update()
def _skip(self) -> None:
"""End the tour now. Same finish as reaching the last step.
Skipping and completing are the SAME outcome deliberately: a tour that
reappeared because it was dismissed rather than read is one the user
cannot get rid of.
"""
self._finish()
def _finish(self) -> None:
"""Close the overlay and tell the caller it is done.
A failing callback does not stop the overlay closing: the tour is
finished either way, and leaving it on screen because something
downstream raised is the worse of the two outcomes.
"""
if self._on_finish is not None:
try:
self._on_finish()
except Exception:
LOG.debug("tour finish callback failed", exc_info=True)
else:
mark_tour_seen()
self._window.removeEventFilter(self)
self.close()
self.deleteLater()
def _widget_rect_in_window(widget: QWidget,
window: QMainWindow) -> Optional[QRect]:
"""Return ``widget``'s bounding rectangle in the window's coord space."""
try:
from PySide6.QtWidgets import QMenu
if isinstance(widget, QMenu):
bar = window.menuBar()
if bar.isNativeMenuBar() or not bar.isVisible():
return None
local = bar.actionGeometry(widget.menuAction())
if local.isEmpty():
return None
return QRect(bar.mapTo(window, local.topLeft()), local.size())
if not widget.isVisibleTo(window) or widget.window() is not window:
return None
top_left = widget.mapTo(window, QPoint(0, 0))
return QRect(top_left, widget.size())
except Exception:
return None
def _ghost_btn_qss() -> str:
"""Return the stylesheet for the tour's secondary button.
:returns: the QSS. Literal colours rather than the theme's, because the
first-run tour is shown before a theme has been chosen.
"""
return (
"QPushButton {"
" background: transparent;"
" color: #a1a6ad;"
" border: 1px solid #2a2d33;"
" border-radius: 6px;"
" padding: 6px 14px;"
" font-family: 'Open Sans', sans-serif;"
"}"
"QPushButton:hover { color: #e5e5e5; border-color: #4A9EFF; }"
)
def _primary_btn_qss() -> str:
"""Return the stylesheet for the tour's primary button.
:returns: the QSS. Literal colours, for the same reason as the ghost
button.
"""
return (
"QPushButton {"
" background: #4A9EFF;"
" color: #000;"
" border: none;"
" border-radius: 6px;"
" padding: 6px 18px;"
" font-family: 'Open Sans', sans-serif;"
" font-weight: 600;"
"}"
"QPushButton:hover { background: #66B2FF; }"
)
[docs]
def maybe_show_tour(window: QMainWindow,
force: bool = False) -> Optional[_TourOverlay]:
"""Show the tour if it hasn't been seen (or if ``force=True``).
:param window: the MainWindow to overlay.
:param force: skip the "seen" check and show anyway.
:returns: the overlay widget (already visible) or None if the
tour was skipped because it had been seen.
"""
if getattr(window, "_pathway_walkthrough_active", False):
return None
if not force and was_tour_shown():
return None
overlay = _TourOverlay(window, DEFAULT_TOUR)
overlay.show()
overlay.raise_()
overlay.setFocus()
return overlay