Source code for spacr.qt.first_run

"""
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, ), ]
[docs] def find_menu(window: QMainWindow, title: str) -> Optional[QWidget]: """The window's menu-bar menu titled ``title``, ignoring ``&``. Found through ``findChildren`` rather than by walking the menu bar's actions and calling ``QAction.menu()``. That reading is the obvious one and it does not survive on PySide6 6.11: the QMenu wrapper it returns is only valid while the QAction wrapper it came off is alive, so the menu went stale the moment this function returned — "Internal C++ object (PySide6.QtWidgets.QMenu) already deleted" on the very next line — and keeping the owners alive as attributes segfaulted during the next event dispatch instead. ``findChildren`` hands back children the menu bar owns in C++, which stay valid for as long as the window does. :param window: the live main window. :param title: menu title without its mnemonic ampersand. """ from PySide6.QtWidgets import QMenu try: bar = window.menuBar() if bar is None: return None menus = bar.findChildren(QMenu) except Exception: return None for menu in menus: try: if menu.title().replace("&", "") == title: return menu except RuntimeError: continue return 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