Source code for spacr.qt.widgets.setup_slides

"""Present first-run preferences as a short sequence of explained choices.

Each slide covers one preference group and writes through the existing setup
model. The animated backdrop, translucent card, and pointer-responsive border
are decorative; preference editing and persistence remain available when
those effects cannot be rendered.

:mod:`spacr.qt.setup_screen` holds the model and is the only writer of a
preference; this module is presentation alone. ``setup_dialog`` is the
earlier grouped-form layout of the same questions.
"""
from __future__ import annotations

import logging
from typing import Any, Dict, Optional, Tuple

from PySide6.QtCore import (QEasingCurve, QEvent, QPointF,
                            QPropertyAnimation, Qt, QTimer)
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (QComboBox, QDialog, QFormLayout,
                               QGraphicsOpacityEffect,
                               QHBoxLayout, QLabel, QPushButton,
                               QStackedWidget, QVBoxLayout, QWidget)

LOG = logging.getLogger("spacr.qt.setup_slides")

#: Setup slides as ``(title, explanation, setting keys)`` tuples.
#:
#: The order moves from interface choices, through execution preferences, to
#: the terms of use.
#: Space between a question and the control that answers it.
#:
#: A NUMBER, NOT WHATEVER IS LEFT OVER. The rows used to put a
#: stretch between the two, which set them 771 px apart on a
#: 980 px card -- the caption on one edge and its control on the
#: other, with the whole width of the slide in between.
FORM_GAP_PX = 24

SLIDES: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = (
    ("Language",
     "Every label, tooltip and message spaCR shows you. You can change it "
     "later in Preferences, and nothing about your data depends on it.",
     ("language",)),
    ("Theme",
     "How spaCR looks, and whether its colours are chosen to stay "
     "distinguishable without colour vision. Both take effect as you pick "
     "them, so you can see what you are choosing.",
     ("theme", "colour_blind")),
    ("How it runs",
     "How much of this machine spaCR keeps between runs: processes, "
     "caches and GPU memory. Laptop keeps the least — for 8 GB or on "
     "battery; Workstation the most. The same spaCR code runs at every "
     "level, and the reproducibility hash records what each run used.",
     ("spacr_mode", "hash_inputs")),
    ("Terms of use",
     "Review the terms of use and scroll to the end to enable acceptance. "
     "Use the license link to read the full BSD 3-Clause "
     "License.",
     ()),
    ("Done", "Welcome to spaCR", ()),
)

#: The title of the slide carrying the terms of use.
#:
#: Named rather than matched on, because that slide is the one page in the
#: sequence that is neither a form nor the closing word: it builds itself and
#: it refuses to be left.
TERMS_SLIDE = "Terms of use"

#: The caption on the animation question, and the one row of the theme
#: slide that is not one of the setup model's own questions.
#:
#: "in the startup, under theme should be annimation, degault to blobs." It
#: is asked here rather than added to :func:`spacr.qt.setup_screen.questions`
#: because it is not written through that screen's apply pass: the backdrop
#: has one seam, :func:`spacr.qt.preferences.set_ambient_animation`, which
#: both stores the choice and turns the backdrop on or off, and a second
#: writer for the same preference is how a stored None ends up drawing.
ANIMATION_LABEL = "Animation"

#: How close to the bottom of the terms counts as having reached it.
#:
#: PIXELS, because a scroll bar does not always land exactly on its maximum:
#: a wheel notch, a fling on a touchpad and a drag all stop where they stop,
#: and a gate that demands the exact maximum is a gate that stays shut for a
#: reader who is looking at the last line.
TERMS_END_SLACK = 4

#: A localized greeting for every language offered on the language slide.
#:
#: The greeting provides immediate confirmation without redrawing the window
#: beneath the setup dialog.
GREETINGS: Dict[str, str] = {
    "en": "Hello", "sv": "Hej", "de": "Hallo", "es": "Hola",
    "fr": "Bonjour", "pt": "Olá", "is": "Halló", "hi": "नमस्ते",
    "ko": "안녕하세요", "zh_CN": "你好",
}

#: How much faster the backdrop runs than the ambient default.
BACKDROP_SPEED = 1.5

BACKDROP_THEME = "data_art_impulse_lens"

#: Milliseconds one slide takes to fade into the next.
#:
#: A CROSS-FADE, NOT A CUT. `QStackedWidget.setCurrentIndex` swaps the page
#: between two frames, so the card's contents changed instantly under a rim
#: that took half a second to travel -- two speeds in one gesture, which is
#: what read as unfinished. 260 ms is long enough to see and short enough
#: that six slides do not feel like waiting.
FADE_MS = 260

#: Milliseconds the greeting is held before the first Next takes effect.
#:
#: "there should be a lag after the first next click to make time for Hello
#: in the chosen language" -- the greeting is the only proof the language
#: choice took, and it appears on the slide the user is leaving. Without a
#: pause it is on screen for one frame of a fade.
#:
#: THE HOLD INCLUDES THE FADE. At 850 ms the word reached full opacity at
#: about 500 and was gone by 850, so it was properly legible for a third of
#: a second and was reported as never appearing at all.
GREETING_MS = 1600

#: The face every slide is set in.
#:
#: LIGHT, AND IT REALLY IS LIGHT: `OpenSans-Light.ttf` ships in
#: `spacr/resources/font/open_sans/static`, so the weight resolves to the
#: face rather than to a synthesised one -- provided the bundled fonts have
#: been registered, which is why `_use_the_light_face` loads them itself
#: instead of assuming the application already did.
SLIDE_FONT = "Open Sans"

#: Point size of the word on the closing slide.
#: Corner radius of the setup window, in pixels.
#:
#: ONE NUMBER FOR TWO SURFACES. The card and the ambient backdrop behind it
#: are the same rectangle, so they must round by the same amount or the
#: backdrop's corners show past the card's and the dialog looks like two
#: stacked windows. SetupCard's own default is 18; this names it so the
#: backdrop can be told the same thing.
CARD_RADIUS = 18

DONE_POINTS = 44

#: Point size of the greeting.
#:
#: BIG ENOUGH TO BE SEEN AT ALL. At body size it was one short word in the
#: corner of a card, at full opacity for about a third of a second -- which
#: is why it was reported as not appearing. It is the answer to the question
#: just asked, so it is the size of an answer.
GREETING_POINTS = 30

#: How far down the card the greeting floats, as a fraction of its height.
#:
#: A BAND NOTHING ELSE USES. The question rows sit in the upper half and the
#: buttons along the bottom edge, so this is empty on every slide -- which
#: is what lets the word leave slowly instead of being switched off to make
#: room for what comes next.
#:
#: It sits TWO rows higher than it did, to leave room beneath it for what
#: the machine can actually run.
#:
#: Reported as the greeting on the start screen needing to sit one or two rows up so it does not overlap with the GPU text". The note grew a
#: capability table on 2026-08-31 and got taller; the greeting had not
#: moved since, so the two met.
GREETING_BAND = 0.50

#: How far down the card the GPU note sits: the row the greeting left.
GPU_NOTE_BAND = 0.78

#: What spaCR needs a GPU for, said once on the first slide.
#:
#: NOT A WARNING AND NOT A GATE. Everything else works without one -- the
#: measurements, the regression, every figure -- so this says which two
#: steps are the ones that will be slow or impossible, and leaves the
#: decision to the reader.
#: NOT "AN NVIDIA GPU" ANY MORE, and the old wording was not merely
#: imprecise -- it was wrong on the machine that reported it. spaCR now
#: dispatches to CUDA, ROCm, Apple Metal (which drives Apple Silicon AND
#: AMD cards in Intel Macs) and Intel XPU.
GPU_REQUIREMENT = (
    "spaCR tasks are GPU accelerated and are compatible with NVIDIA, AMD, "
    "Apple, and Intel GPUs. GPU acceleration is orders of magnitude faster "
    "than CPU for matrix multiplication tasks.")

#: The backend that drives each accelerator, for the "GPU: <name>: <lib>"
#: line. The LIBRARY, not the vendor -- a user reading "Metal" beside an
#: AMD card learns the thing that explains why ROCm is irrelevant on their
#: machine. Naming the vendor instead is the mistake this avoids.
GPU_LIBRARIES = {
    "cuda": "CUDA",
    "rocm": "ROCm",
    "mps": "Metal",
    "xpu": "XPU",
    "directml": "DirectML",
}

#: The table's rows: ``(library, capability prefix, task)``.
#:
#: The middle column is GPU or CPU per row and is DERIVED -- matched
#: against the start of what `accelerator.capabilities()` returns, so a
#: detail sentence can be reworded without silently emptying a row.
#:
#: "Live backdrop" is what the renderer is called in `capabilities()` and
#: in the code; the request said "Lave", which is that word typed in a
#: hurry rather than a different thing.
GPU_TABLE_ROWS = (
    ("Cellpose", "Segmentation", "Segmentation"),
    ("Torch models", "Model inference", "Classification"),
    ("Live backdrop", "Live backdrop", "Visualization"),
    ("UMAP / t-SNE / cluster", "UMAP", "Machine learning"),
)

#: The colours the verdict is drawn in on a dark theme.
GPU_YES_INK = "#3FB950"
GPU_NO_INK = "#F85149"


[docs] def verdict_ink(ok: bool) -> str: """The colour a GPU verdict is drawn in on the theme in force. :data:`GPU_YES_INK` and :data:`GPU_NO_INK` are the dark theme's green and red, and on a light page that green is under 2.5:1. A light theme draws the verdict in its own ``success`` and ``error`` instead, which its contrast rules hold readable. :param ok: whether the verdict is good news. :returns: a hex colour. """ try: from ..theme import active_palette, relative_luminance palette = active_palette() if relative_luminance(palette["bg"]) > relative_luminance( palette["fg"]): return palette["success"] if ok else palette["error"] except Exception: # noqa: BLE001 LOG.debug("no palette for the GPU verdict", exc_info=True) return GPU_YES_INK if ok else GPU_NO_INK
#: What to say when the card is there and torch cannot use it. #: #: A DIFFERENT PROBLEM FROM NO CARD, with a different fix, so it gets a #: different sentence. A CPU-only torch build and a driver older than the #: CUDA runtime torch was built against both present as "cuda not #: available", and `spacr-doctor` is what tells the two apart -- it exists #: for this case and says which one it is. GPU_DOCTOR_HINT = ( "The card is there but torch cannot use it. " "Run spacr-doctor to find out which part of CUDA is missing.") GPU_MAC_CPU_HINT = ( "PyTorch cannot use this Mac's graphics, so spaCR runs these tasks on the CPU. " "Nothing needs to be installed.") #: Milliseconds the greeting takes to fade AWAY. #: #: Slower than it arrives. A word that leaves at the speed it came reads as #: being taken away; one that lingers reads as being finished with. GREETING_LEAVE_MS = 700 #: Milliseconds the greeting takes to fade up. #: #: IT ARRIVES, it does not appear. A word that is simply switched on reads #: as a label that was always going to be there; one that fades up reads as #: an answer to what was just chosen, which is what it is. GREETING_FADE_MS = 420
[docs] def greeting_for(code: str) -> str: """"Hello" in ``code``, falling back to English. :param code: language code, a key of :data:`GREETINGS` such as ``"sv"`` or ``"zh_CN"``; unknown or empty codes give ``"Hello"``. """ return GREETINGS.get(str(code or ""), GREETINGS["en"])
def _gpu_library() -> str: """The backend driving this machine's accelerator, or "". CUDA, ROCm, Metal, XPU, DirectML -- the library, not the vendor. A user reading "Metal" beside an AMD card learns the thing that explains why ROCm is irrelevant on their machine. Naming the vendor instead is the mistake this avoids. """ try: from ...accelerator import resolve return GPU_LIBRARIES.get(resolve().kind, "") except Exception: # noqa: BLE001 LOG.debug("could not name the accelerator library", exc_info=True) return ""
[docs] def graphics_card() -> Tuple[bool, str]: """``(usable, name)`` for the machine's graphics card. Usable means torch can reach it: a card spaCR cannot run on is not a compatible card however well the driver reports it. Torch is asked first for that reason, and NVML second because it names the card even when torch was built without CUDA -- which is the case worth telling apart, since the answer there is "install a CUDA build", not "buy a card". :returns: ``(True, 'NVIDIA GeForce RTX 3090')`` when segmentation can run on it; ``(False, name)`` when it cannot, with the best name available; ``(False, '')`` when nothing could be identified. """ name = "" usable = False try: import torch as _torch_module from ...accelerator import inspect_torch found = inspect_torch(_torch_module) if found.is_gpu: return True, found.name or found.label if found.detected and not found.usable: return False, found.name or found.label except Exception: # noqa: BLE001 LOG.debug("the accelerator resolver could not be asked", exc_info=True) try: import torch if torch.cuda.is_available() and torch.cuda.device_count(): usable = True name = str(torch.cuda.get_device_name(0)) except Exception: # noqa: BLE001 LOG.debug("torch could not be asked about the GPU", exc_info=True) if name: return usable, name try: from ..widgets.home import _nvml nvml = _nvml() if nvml is not None and nvml.nvmlDeviceGetCount(): handle = nvml.nvmlDeviceGetHandleByIndex(0) raw = nvml.nvmlDeviceGetName(handle) name = raw.decode() if isinstance(raw, bytes) else str(raw) except Exception: # noqa: BLE001 LOG.debug("NVML could not name the GPU", exc_info=True) return usable, name
def _say(text: str, **values) -> str: """Translate one caption, falling back to the English it was given. A local shim rather than a module-level `from ..i18n import tr`: this module is imported during application start-up, before the language preference has necessarily been read, so the lookup happens per call. """ try: from ..i18n import tr return tr(text, **values) except Exception: # noqa: BLE001 LOG.debug("no translation available", exc_info=True) return text.format(**values) if values else text def _still_a_widget(widget: object | None) -> bool: """Whether a Python Qt wrapper still owns its C++ object.""" if widget is None: return False try: from shiboken6 import isValid except Exception: # noqa: BLE001 return True return bool(isValid(widget))
[docs] class SetupSlides(QDialog): """The setup screen: one question per slide, over a moving backdrop. :param parent: parent widget. """ def __init__(self, parent: Optional[QWidget] = None): """Build the first-run slide deck. Frameless, like the rest of the shell: the card it draws has rounded corners, and a square window frame around them is the seam this avoids. :param parent: parent widget, or ``None``. """ super().__init__(parent) self.setWindowTitle("Set spaCR up") self.setModal(True) self.setProperty("spacrIndependentBackdrop", True) self._editors: Dict[str, QWidget] = {} self._index = 0 self._go_frameless() outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) self._backdrop = self._install_backdrop() from .setup_card import SetupCard self.card = SetupCard(self, radius=CARD_RADIUS) self.card.setMouseTracking(True) self.setMouseTracking(True) column = QVBoxLayout(self.card) column.setContentsMargins(28, 28, 28, 22) column.setSpacing(12) column.addStretch(1) self._title = QLabel("") self._title.setObjectName("CardTitle") column.addWidget(self._title) self._blurb = QLabel("") self._blurb.setObjectName("Muted") self._blurb.setWordWrap(True) column.addWidget(self._blurb) self._greeting = QLabel("", self.card) self._greeting.setObjectName("CardTitle") self._greeting.setAlignment(Qt.AlignCenter) self._greeting.setAttribute(Qt.WA_TransparentForMouseEvents, True) self._greeting.setVisible(False) self._gpu_note = QLabel("", self.card) self._gpu_note.setObjectName("Muted") self._gpu_note.setAlignment(Qt.AlignCenter) self._gpu_note.setWordWrap(True) self._gpu_note.setAttribute(Qt.WA_TransparentForMouseEvents, True) self._gpu_note.setVisible(False) self._say_what_the_gpu_is() self._pages = QStackedWidget() column.addWidget(self._pages) self._build_pages() column.addStretch(1) row = QHBoxLayout() self._back = QPushButton("‹ Back") self._back.clicked.connect(self.previous) row.addWidget(self._back) self._where = QLabel("") self._where.setObjectName("Muted") self._where.setAlignment(Qt.AlignCenter) row.addWidget(self._where, 1) self._next = QPushButton("Next ›") self._next.clicked.connect(self.next) row.addWidget(self._next) column.addLayout(row) self._clear_the_containers() self._use_the_light_face() self._fade = None self._hello = None self._pending = None #: Whether the greeting has already been waited for once. self._greeted = False self._show_slide(0) self.resize(720, 560) def _use_the_light_face(self) -> None: """Set every slide in Open Sans Light. SET ON THE CARD, not on each label: Qt propagates a font to children that have not asked for one of their own, so one call covers the titles, the prose, the controls and the buttons -- and the two labels that DO want a weight of their own (the closing word and the greeting) set it after this and keep it. The bundled faces are registered here rather than assumed: this dialog is the FIRST thing a fresh profile sees, and on that path it can be built before anything else has loaded them. """ try: from ..app import _load_bundled_fonts _load_bundled_fonts() except Exception: # noqa: BLE001 LOG.debug("bundled fonts are not loadable here", exc_info=True) face = QFont(SLIDE_FONT) face.setWeight(QFont.Light) self.card.setFont(face) def _clear_the_containers(self) -> None: """Stop the layout containers painting over the backdrop.""" try: from ..theme import make_transparent except Exception: # noqa: BLE001 LOG.debug("no theme helper for transparency", exc_info=True) return holders = [self._pages] holders += [self._pages.widget(i) for i in range(self._pages.count())] holders += [w for w in self.findChildren(QWidget) if w.property("spacrClearContainer")] try: make_transparent(*[w for w in holders if w is not None]) except Exception: # noqa: BLE001 LOG.debug("a container would not go transparent", exc_info=True) def _build_pages(self) -> None: """One page per slide, from the model's own question list.""" from ..setup_screen import current, questions asked = {q[0]: q for q in questions()} answers = current() for index, (title, blurb, keys) in enumerate(SLIDES): if title == TERMS_SLIDE: self._pages.addWidget(self._terms_page()) continue if index == len(SLIDES) - 1 and not keys: self._pages.addWidget(self._closing_page(title, blurb)) continue page = QWidget() form = QFormLayout(page) form.setContentsMargins(0, 8, 8, 0) form.setVerticalSpacing(14) form.setHorizontalSpacing(FORM_GAP_PX) form.setLabelAlignment(Qt.AlignLeft | Qt.AlignVCenter) form.setFormAlignment(Qt.AlignLeft | Qt.AlignTop) form.setFieldGrowthPolicy(QFormLayout.FieldsStayAtSizeHint) for key in keys: if key not in asked: continue form.addRow(*self._row(asked[key], answers.get(key))) if key == "theme": animation = self._animation_row() if animation is not None: form.addRow(*animation) self._pages.addWidget(page) def _go_frameless(self) -> bool: """Drop the title bar and let the card's rounded corners show. The setup screen is dismissed by its own buttons and by Escape, so the close and minimise buttons were chrome around chrome. It stays movable: `glass._DragByBackground` drags a window by its empty background, which is what the title bar used to be for. """ from PySide6.QtCore import Qt try: self.setAttribute(Qt.WA_TranslucentBackground, True) self.setWindowFlags(self.windowFlags() | Qt.FramelessWindowHint) from .glass import _DragByBackground, _paint_nothing_behind_the_card _paint_nothing_behind_the_card(self) _DragByBackground(self) return True except Exception: # noqa: BLE001 LOG.debug("the setup screen would not go frameless", exc_info=True) return False #: The greeting's fade-out, held so it is not collected mid-animation. _goodbye = None def _closing_page(self, title: str, blurb: str) -> QWidget: """The last slide: one word, centred, with its sentence under it. The shared header is HIDDEN for this slide rather than repeated -- the title is the word in the middle, and having it twice on one screen is the layout saying it does not know which one is the heading. """ page = QWidget() column = QVBoxLayout(page) column.setContentsMargins(0, 0, 0, 0) column.setSpacing(10) column.addStretch(1) self._done_word = QLabel(str(title)) self._done_word.setAlignment(Qt.AlignCenter) try: from ..theme import active_palette ink = f"color: {active_palette()['accent']}; " except Exception: # noqa: BLE001 LOG.debug("no palette for the closing word", exc_info=True) ink = "" self._done_word.setStyleSheet( f"{ink}font-family: '{SLIDE_FONT}'; font-weight: 300; " f"font-size: {DONE_POINTS}pt;") column.addWidget(self._done_word) under = QLabel(str(blurb)) under.setObjectName("Muted") under.setAlignment(Qt.AlignCenter) under.setWordWrap(True) column.addWidget(under) column.addStretch(1) return page def _terms_page(self) -> QWidget: """The terms, an acceptance gated on having read them, and the line that says what is missing. THE ACCEPTANCE IS DISABLED UNTIL THE END OF THE TERMS HAS BEEN ON SCREEN, and the terms are greyed with it: one state drawn on both halves, so the page reads as one thing waiting rather than as a switch that happens to be dead. The enabling IS the evidence that the text went past the reader. THE NEXT BUTTON IS NOT DISABLED HERE. A control that does nothing and says nothing leaves the reader to guess which of the things on the page is stopping them, so the button stays live and answers the press with :data:`spacr.qt.terms.WHY_NOT_YET` in the accent colour. """ from PySide6.QtWidgets import QScrollArea from .. import terms as terms_module from .toggle import Toggle page = QWidget() column = QVBoxLayout(page) column.setContentsMargins(0, 0, 0, 0) column.setSpacing(10) body = QLabel(terms_module.terms_text()) body.setObjectName("Muted") body.setWordWrap(True) body.setAlignment(Qt.AlignLeft | Qt.AlignTop) self._terms_body = body scroll = QScrollArea(page) scroll.setWidgetResizable(True) scroll.setWidget(body) scroll.setFrameShape(QScrollArea.NoFrame) scroll.setProperty("spacrClearContainer", True) scroll.viewport().setProperty("spacrClearContainer", True) self._terms_scroll = scroll bar = scroll.verticalScrollBar() if bar is not None: bar.valueChanged.connect(self._look_at_the_terms_gate) bar.rangeChanged.connect(self._look_at_the_terms_gate) column.addWidget(scroll, 1) where = QLabel( f'<a href="{terms_module.LICENSE_URL}">' f"{terms_module.LICENSE_NAME}</a> &nbsp;·&nbsp; " f"{terms_module.REQUIRED_NOTICE}") where.setObjectName("Muted") where.setOpenExternalLinks(True) where.setWordWrap(True) column.addWidget(where) self._scroll_hint = QLabel(_say(terms_module.SCROLL_HINT), page) self._scroll_hint.setObjectName("Muted") self._scroll_hint.setWordWrap(True) column.addWidget(self._scroll_hint) self._agree = Toggle(_say(terms_module.AGREE_LABEL), page) self._agree.toggled.connect(self._on_agreement_toggled) column.addWidget(self._agree) self._agree_note = QLabel("", page) self._agree_note.setWordWrap(True) self._agree_note.setVisible(False) try: from ..theme import active_palette self._agree_note.setStyleSheet( f"color: {active_palette()['accent']};") except Exception: # noqa: BLE001 LOG.debug("no palette for the terms note", exc_info=True) column.addWidget(self._agree_note) self._terms_read = False self._draw_the_terms_gate(False) return page def _look_at_the_terms_gate(self, *_args) -> None: """Re-read the gate and redraw it. The signal handler.""" self._draw_the_terms_gate(self.terms_were_read())
[docs] def terms_were_read(self) -> bool: """Always ``True``: the acceptance is not gated on scrolling. There is no scroll gate. Dragging a scroll bar to the bottom of a long document does not prove it was read, so scrolling is not a condition of acceptance. The acceptance remains. The full text is still on the page and still scrollable for anyone who wants it, the checkbox is still explicit, and :func:`spacr.qt.terms.record_agreement` still records the version and the moment. Only the greying is removed. Kept as a method rather than deleted because the slide, the Next button and the tests all ask this question, and one answer in one place is easier to be sure of than a gate removed from four. """ self._terms_read = True return True
def _draw_the_terms_gate(self, read: bool) -> None: """Put the gate's one state on both halves of the page.""" box = getattr(self, "_agree", None) if _still_a_widget(box): box.setEnabled(bool(read)) body = getattr(self, "_terms_body", None) if _still_a_widget(body): body.setStyleSheet("" if read else f"color: {self._dim_ink()};") hint = getattr(self, "_scroll_hint", None) if _still_a_widget(hint): hint.setVisible(not read) @staticmethod def _dim_ink() -> str: """The palette's dim ink, or a grey that works without one.""" try: from ..theme import active_palette palette = active_palette() return str(palette.get("fg_dim") or palette.get("fg_muted") or "#6b6f76") except Exception: # noqa: BLE001 LOG.debug("no palette for the greyed terms", exc_info=True) return "#6b6f76" def _on_agreement_toggled(self, agreed: bool) -> None: """Drop the complaint the moment the box is ticked.""" note = getattr(self, "_agree_note", None) if note is not None and agreed: note.setVisible(False)
[docs] def agreed_to_terms(self) -> bool: """Whether the acceptance box is ticked on this screen.""" box = getattr(self, "_agree", None) return bool(box is not None and box.isChecked())
def _refuse_to_leave_the_terms(self) -> int: """Say what is missing and stay put. Returns the slide still shown. TWO THINGS CAN BE MISSING and they need different sentences. An unticked switch is answered by :data:`spacr.qt.terms.WHY_NOT_YET`; a switch that cannot be ticked yet is answered by that AND by the reason it is greyed, because "tick the box above" is not actionable advice about a box that will not take a tick. """ from .. import terms as terms_module read = self.terms_were_read() note = getattr(self, "_agree_note", None) if note is not None: said = _say(terms_module.WHY_NOT_YET) if not read: said = f"{_say(terms_module.SCROLL_HINT)} {said}" note.setText(said) note.setVisible(True) target = getattr(self, "_agree" if read else "_terms_scroll", None) if target is not None: target.setFocus() return self._index def _row(self, question, value) -> Tuple[QLabel, QWidget]: """The caption and the control, for one row of the page's form. Returned as a pair rather than as a finished layout so that every row on a page shares ONE form: two columns, one gap, and captions that line up with the captions above and below them. """ key, caption, _get, _set, choices = question label = QLabel(str(caption)) editor = self._editor(key, choices, value) self._editors[key] = editor return label, editor def _editor(self, key: str, choices, value) -> QWidget: """A combo or a slider. NEVER A CHECKBOX. "aslo in the startup all the booleans should be sliders" -- a tick box is a form control and this is not a form. A slider reads as a STATE rather than as a task, which is what these settings are. """ if choices: box = QComboBox() for data, caption in choices: box.addItem(str(caption), data) index = box.findData(value) box.setCurrentIndex(index if index >= 0 else 0) if key == "language": box.currentIndexChanged.connect(self._say_hello) box.currentIndexChanged.connect(self._apply_language) if key in ("theme", "colour_blind"): box.currentIndexChanged.connect( lambda _i, k=key: self._apply_look(k)) return box from .toggle import Toggle slider = Toggle() slider.setChecked(bool(value)) return slider def _animation_row(self) -> Optional[Tuple[QLabel, QWidget]]: """The backdrop question, asked under the theme it belongs with. EVERY CHOICE THE APPLICATION HAS, ``None`` included -- the list is :data:`spacr.qt.widgets.ambient.ANIMATION_CHOICES`, so a reader who finds the motion distracting can say so on the way in rather than going looking for it afterwards. IT OPENS ON WHAT IS ALREADY TRUE. The default is spaCR field (``data_art_impulse_lens``), as defined by :data:`spacr.qt.widgets.ambient.DEFAULT_THEME` and what :func:`spacr.qt.preferences.get_ambient_animation` falls back to -- the slide shows the application's own default rather than a second opinion about it. :returns: the row, or ``None`` when there is no ambient module to ask -- in which case the theme slide is the two questions it was, rather than a labelled row with nothing in it. """ try: from .ambient import (ANIMATION_CHOICES, DEFAULT_THEME, animation_label, animation_note) except Exception: # noqa: BLE001 LOG.debug("no ambient module to ask about the backdrop", exc_info=True) return None try: from ..preferences import get_ambient_animation chosen = get_ambient_animation() except Exception: # noqa: BLE001 LOG.debug("the stored animation could not be read", exc_info=True) chosen = DEFAULT_THEME box = QComboBox() box.setObjectName("SetupAnimation") for index, name in enumerate(ANIMATION_CHOICES): box.addItem(_say(animation_label(name)), name) try: box.setItemData(index, _say(animation_note(name)), Qt.ToolTipRole) except Exception: # noqa: BLE001 LOG.debug("no note for animation %s", name, exc_info=True) where = box.findData(chosen) if where < 0: where = box.findData(DEFAULT_THEME) if where < 0: LOG.debug("no %s among the animations offered", DEFAULT_THEME) where = 0 box.setCurrentIndex(where) box.currentIndexChanged.connect(self._apply_animation) self._animation = box return QLabel(_say(ANIMATION_LABEL)), box
[docs] def animation_choice(self) -> str: """Which backdrop the slide is showing, or ``""`` with no row.""" box = getattr(self, "_animation", None) return "" if box is None else str(box.currentData() or "")
def _apply_animation(self, *_args) -> None: """Store the backdrop choice, through the one seam that owns it. :func:`spacr.qt.preferences.set_ambient_animation` both records the choice and turns the backdrop on or off, so writing the theme key directly would leave a profile that chose None with the animation still enabled -- and one that chose Blobs after switching it off with silence. """ name = self.animation_choice() if not name: return try: from ..preferences import set_ambient_animation set_ambient_animation(name) except Exception: # noqa: BLE001 LOG.debug("could not store the animation choice", exc_info=True) def _say_hello(self, *_args) -> None: """Set the greeting text for the language currently chosen. Setting it is not showing it: :meth:`_show_the_greeting` is what puts it on screen, and only the first Next calls that. """ box = self._editors.get("language") if box is None: return self._greeting.setText(greeting_for(box.currentData())) def _show_the_greeting(self) -> None: """Fade the greeting up, in the accent colour. BLUE, FROM THE PALETTE rather than a literal: the accent is what the theme calls its own, so the greeting matches the rim that is running round the card as it appears. """ self._say_hello() try: from ..theme import active_palette accent = active_palette()["accent"] self._greeting.setStyleSheet( f"color: {accent}; font-family: '{SLIDE_FONT}'; " f"font-weight: 300; font-size: {GREETING_POINTS}pt;") except Exception: # noqa: BLE001 LOG.debug("no palette for the greeting", exc_info=True) self._place_the_greeting() self._greeting.setVisible(True) try: effect = QGraphicsOpacityEffect(self._greeting) self._greeting.setGraphicsEffect(effect) animation = QPropertyAnimation(effect, b"opacity", self) animation.setDuration(GREETING_FADE_MS) animation.setStartValue(0.0) animation.setEndValue(1.0) animation.setEasingCurve(QEasingCurve.OutCubic) self._hello = animation animation.start() except Exception: # noqa: BLE001 LOG.debug("no fade for the greeting", exc_info=True) self._hello = None def _fade_the_greeting_away(self) -> None: """Take the greeting off gently, and only if it is on. The abrupt version was ``setVisible(False)`` the instant the slide changed. Nothing depends on the word being gone -- it floats over a band no slide uses -- so it can be given the time to leave. """ greeting = getattr(self, "_greeting", None) card = getattr(self, "card", None) if greeting is None or card is None: return if not greeting.isVisibleTo(card): return try: effect = QGraphicsOpacityEffect(greeting) greeting.setGraphicsEffect(effect) animation = QPropertyAnimation(effect, b"opacity", self) animation.setDuration(GREETING_LEAVE_MS) animation.setStartValue(1.0) animation.setEndValue(0.0) animation.setEasingCurve(QEasingCurve.InCubic) animation.finished.connect( lambda: greeting.setVisible(False)) self._goodbye = animation animation.start() except Exception: # noqa: BLE001 LOG.debug("no fade for the greeting", exc_info=True) greeting.setVisible(False) self._goodbye = None def _place_the_greeting(self) -> None: """Put the greeting in its band, and the GPU note in its own. TWO INDEPENDENT PLACEMENTS. The greeting arrives only when the language is confirmed, so it does not exist while the first slide is being read -- and placing the note behind a check for the greeting left it at the geometry a QLabel is born with, the top left corner of the card, where it read as stray text above the language list until the first Next took it away. """ card = getattr(self, "card", None) if card is None: return if self._greeting: height = self._greeting.sizeHint().height() self._greeting.setGeometry( 0, int(card.height() * GREETING_BAND), card.width(), height) self._greeting.raise_() self._place_the_gpu_note() def _place_the_gpu_note(self) -> None: """Put the GPU note in its band, whatever the greeting is doing.""" card = getattr(self, "card", None) note = getattr(self, "_gpu_note", None) if card is None or note is None or note.isHidden(): return margin = 28 width = max(1, card.width() - 2 * margin) note.setFixedWidth(width) height = note.heightForWidth(width) if height <= 0: height = note.sizeHint().height() top = int(card.height() * GPU_NOTE_BAND) floor = card.height() - margin back = getattr(self, "_back", None) if back is not None and back.parent() is not None: placed = False try: top_of_buttons = back.mapTo(card, back.rect().topLeft()).y() if top_of_buttons > 0: floor = min(floor, top_of_buttons - 12) placed = True except (AttributeError, RuntimeError): pass if not placed: try: reserved = back.sizeHint().height() except (AttributeError, RuntimeError): reserved = 0 if reserved > 0: floor = min(floor, card.height() - margin - reserved - 12) top = max(0, min(top, floor - height)) note.setGeometry(margin, top, width, height) note.raise_() def _say_what_the_gpu_is(self) -> None: """Write the requirement and this machine's verdict into the note. GREEN OR RED, and the card is named either way: "no compatible GPU" on its own leaves the reader wondering whether spaCR looked. """ usable, name = graphics_card() hint = "" if name: library = _gpu_library() tail = f": {library}" if library else "" line = (f'{_say("GPU")}: <span style="color:{{ink}};">{name}</span>' f'{tail}') if not usable: hint = _say(GPU_MAC_CPU_HINT if library == "Metal" else GPU_DOCTOR_HINT) else: usable = False line = (f'{_say("GPU")}: ' f'<span style="color:{{ink}};">' f'{_say("none detected")}</span>') ink = verdict_ink(usable) html = [f'<div>{_say(GPU_REQUIREMENT)}</div>', f'<div style="font-weight:600;">{line.format(ink=ink)}</div>'] if hint: html.append(f'<div>{hint}</div>') html.extend(self._what_this_machine_can_do()) self._gpu_note.setText("".join(html)) self._place_the_gpu_note() @staticmethod def _cellpose_label() -> str: """"Cellpose 4", with the 4 taken from the installed package. A hardcoded major version is a claim the next Cellpose falsifies, and this label sits on the first screen a new user sees. """ try: import cellpose version = str(getattr(cellpose, "version", None) or getattr(cellpose, "__version__", "")) major = version.split(".")[0] return f"Cellpose {major}" if major.isdigit() else "Cellpose" except Exception: # noqa: BLE001 return "Cellpose" @staticmethod def _what_this_machine_can_do() -> list: """The capability TABLE: library, GPU-or-CPU, task. A table rather than four sentences. The content was already per-task -- one verdict cannot be honest here, because on Metal the segmentation and the classifier are accelerated while the cuML reductions are not -- but four sentences in a column read as four separate remarks rather than one answer with an axis. The middle column is DERIVED from `accelerator.capabilities()`, the same function `spacr-doctor` and the README table render, so the three surfaces cannot disagree about the same machine. Failures are swallowed. This is decoration on a setup slide, and a machine with a strange accelerator must still reach the button at the bottom of it. """ try: from ...accelerator import capabilities, neural_engines except Exception: # noqa: BLE001 LOG.debug("capabilities unavailable", exc_info=True) return [] rows = [] try: answers = {task: (ok, detail) for task, ok, detail in capabilities()} def _answer(prefix): """The first answer whose task starts with ``prefix``.""" for task, (ok, detail) in answers.items(): if task.startswith(prefix): return ok, detail return None, "" cells = [] for library, prefix, task in GPU_TABLE_ROWS: accelerated, _detail = _answer(prefix) if accelerated is None: LOG.debug("no capability row starts with %r", prefix) continue if library.startswith("Cellpose"): library = SetupSlides._cellpose_label() ink = verdict_ink(bool(accelerated)) where = _say("GPU") if accelerated else _say("CPU") cells.append( f'<tr>' f'<td align="right" style="padding-right:14px;">' f'{_say(library)}</td>' f'<td style="padding-right:14px; color:{ink}; ' f'font-weight:600;">{where}</td>' f'<td style="opacity:0.85;">{_say(task)}</td>' f'</tr>') if cells: rows.append( '<div align="center">' '<table style="margin-top:6px; border-collapse:collapse;">' + "".join(cells) + '</table></div>') for engine in neural_engines(): rows.append( f'<div style="opacity:0.75;">• {engine} — ' f'{_say("detected, not used by spaCR")}</div>') except Exception: # noqa: BLE001 LOG.debug("could not render the capability list", exc_info=True) return [] return rows def _apply_language(self, *_args) -> None: """Put the chosen language into effect and redraw this screen in it. The preference is written NOW rather than on accept, because everything that renders text after this point -- the rest of the slides, the tooltips, any dialog the screen opens -- reads the stored language rather than being handed one. """ box = self._editors.get("language") if box is None: return code = str(box.currentData() or "") if not code: return try: from .. import preferences as prefs prefs.set_language(code) except Exception: # noqa: BLE001 LOG.debug("could not store the language", exc_info=True) self.retranslate()
[docs] def retranslate(self) -> None: """Redraw every caption on this screen in the current language. Through the catalog walker rather than through a `tr()` at each call site: this screen builds its slides from tables, and a walker that remembers each widget's English source can switch from Swedish to Korean without translating a translation. """ try: from ..i18n import retranslate_widget_tree retranslate_widget_tree(self) except Exception: # noqa: BLE001 LOG.debug("could not retranslate the setup screen", exc_info=True) self._show_slide(self._index) self._say_hello()
def _apply_look(self, key: str) -> None: """Put the theme or colour-blind choice into effect immediately.""" from ..setup_screen import questions setter = next((q[3] for q in questions() if q[0] == key), None) editor = self._editors.get(key) if setter is None or editor is None: return try: setter(editor.currentData()) except Exception: # noqa: BLE001 LOG.debug("could not apply %s live", key, exc_info=True) def _show_slide(self, index: int, *, fade: bool = False) -> None: """Show one slide, translating its text as it goes. The slide table holds the English the catalogue is keyed on, so the translation happens here rather than in the table -- which is what lets a slide re-shown after a language change pick up the new rendering. :param index: the slide to show; clamped to the deck. :param fade: cross-fade into it rather than switching. """ index = max(0, min(int(index), len(SLIDES) - 1)) self._index = index title, blurb, _keys = SLIDES[index] self._pages.setCurrentIndex(index) closing = index == len(SLIDES) - 1 self._title.setVisible(not closing) self._blurb.setVisible(not closing) self._title.setText(f"<b>{_say(title)}</b>") self._blurb.setText(_say(blurb)) note = getattr(self, "_gpu_note", None) if note is not None: note.setVisible(index == 0) self._place_the_gpu_note() if index != 0: self._fade_the_greeting_away() self._where.setText( "" if index == 0 else _say("{n} of {total}", n=index + 1, total=len(SLIDES))) self._back.setEnabled(index > 0) self._next.setText(_say("Start spaCR") if index == len(SLIDES) - 1 else _say("Next ›")) self._drop_the_fade() if fade: self._fade_in()
[docs] def showEvent(self, event): # noqa: N802 - Qt naming """Schedule terms-gate evaluation after the window has been laid out. Whether the end of the terms document is visible depends on its rendered viewport. The zero-delay callback runs on the next event-loop turn, after Qt has completed layout for the show event. :param event: the show event, passed to the base class first. """ super().showEvent(event) try: QTimer.singleShot(0, self._look_at_the_terms_gate) except Exception: # noqa: BLE001 LOG.debug("the terms gate could not be re-measured", exc_info=True)
def _fade_in(self) -> None: """Bring the new slide up from transparent. HELD ON THE INSTANCE. A QPropertyAnimation with no owner is collected the moment the method returns, and the fade never runs -- which looks exactly like not having written one. The effect is REMOVED when the fade finishes rather than left in place: a QGraphicsOpacityEffect renders its widget into an offscreen pixmap on every repaint, and leaving six of them alive over a drifting backdrop is a cost paid on every frame for an animation that has ended. """ try: effect = QGraphicsOpacityEffect(self._pages) self._pages.setGraphicsEffect(effect) animation = QPropertyAnimation(effect, b"opacity", self) animation.setDuration(FADE_MS) animation.setStartValue(0.0) animation.setEndValue(1.0) animation.setEasingCurve(QEasingCurve.OutCubic) animation.finished.connect(self._drop_the_fade) self._fade = animation animation.start() except Exception: # noqa: BLE001 LOG.debug("no cross-fade on this platform", exc_info=True) self._fade = None def _drop_the_fade(self) -> None: """Stop any running fade and take the effect off the page stack. STOPPED, not just forgotten: an animation still running would go on driving an effect this is about to delete, and the page would be left at whatever opacity it had reached. """ animation, self._fade = self._fade, None if animation is not None: try: animation.stop() except Exception: # noqa: BLE001 pass try: self._pages.setGraphicsEffect(None) except Exception: # noqa: BLE001 pass
[docs] def next(self) -> int: """Forward one slide, and one clockwise circuit of the rim. Leaving the language slide waits briefly, so the greeting in the chosen language stays visible; it is on the page being left, so without a pause it would be on screen for one frame of a fade. The rim starts its circuit immediately, so the click is answered at once and only the page change is held. The wait happens once. A pause on every return to the first slide would be a delay the user has already sat through. The terms slide is the one page this will not leave. Pressing Next there without the acceptance ticked stays on the slide and says why, rather than greying the button and leaving the reader to work out which control is holding them. """ if SLIDES[self._index][0] == TERMS_SLIDE and not self.agreed_to_terms(): self.card.circuit(clockwise=True) return self._refuse_to_leave_the_terms() if self._index >= len(SLIDES) - 1: self.accept() return self._index self.card.circuit(clockwise=True) if self._index == 0 and not self._greeted: self._greeted = True return self._advance_after_the_greeting() self._show_slide(self._index + 1, fade=True) return self._index
def _advance_after_the_greeting(self) -> int: """Hold the greeting, then move on. Returns the slide still showing. The Next button is disabled for the wait rather than left live: a second click during the pause would queue a second advance and skip a slide. """ self._next.setEnabled(False) self._show_the_greeting() try: self._pending = QTimer(self) self._pending.setSingleShot(True) self._pending.timeout.connect(self._finish_the_greeting) self._pending.start(GREETING_MS) except Exception: # noqa: BLE001 LOG.debug("no timer for the greeting pause", exc_info=True) self._finish_the_greeting() return self._index def _finish_the_greeting(self) -> None: """The pause is over: move to the next slide.""" self._pending = None self._next.setEnabled(True) self._show_slide(min(self._index + 1, len(SLIDES) - 1), fade=True)
[docs] def previous(self) -> int: """Back one slide, and one anticlockwise circuit. The direction shows the user which way they went. """ if self._index <= 0: return self._index self.card.circuit(clockwise=False) self._show_slide(self._index - 1, fade=True) return self._index
[docs] def slide(self) -> int: """Which slide is showing, counting from zero.""" return self._index
[docs] def mouseMoveEvent(self, event): # noqa: N802 - Qt naming """Aim the rim at the pointer. Ignored while a circuit runs. :param event: the mouse move event; its position, mapped into the card, is what the rim flows towards. It is then passed on to the base class. """ try: self.card.flow_towards( self.card.mapFrom(self, event.position().toPoint())) except Exception: # noqa: BLE001 pass super().mouseMoveEvent(event)
[docs] def answers(self) -> Dict[str, Any]: """What the slides currently say.""" out: Dict[str, Any] = {} for key, editor in self._editors.items(): if isinstance(editor, QComboBox): out[key] = editor.currentData() else: out[key] = bool(editor.isChecked()) return out
[docs] def accept(self) -> None: """Close the slides and record that they have been seen. Recorded, so first-run guidance does not greet a returning user as a new one. """ from ..setup_screen import apply, current_version, mark_answered trouble = apply(self.answers()) if trouble: LOG.warning("some setup answers were refused: %s", "; ".join(trouble)) self._record_the_agreement() mark_answered(current_version()) super().accept()
[docs] def reject(self) -> None: """Dismissed at any slide. Still marked answered. Every question has a working default, so a user who closes this has chosen them -- and reopening on every launch until it is filled in would make dismissing it impossible. The terms are the exception, and they are not marked. A dismissal is a choice of defaults; it is not an acceptance of a licence, so nothing is recorded and `open_setup_if_needed` asks again. """ from ..setup_screen import apply, current_version, mark_answered apply(self.answers()) self._record_the_agreement() mark_answered(current_version()) super().reject()
def _record_the_agreement(self) -> None: """Store the accepted terms version, if the box was ticked. WRITTEN ONLY WHEN IT WAS GIVEN. Recording an acceptance on the way out of a screen that was closed would make the record say something the user never did, which is worse than having no record. """ if not self.agreed_to_terms(): return try: from .. import terms as terms_module terms_module.record_agreement(terms_module.TERMS_VERSION) except Exception: # noqa: BLE001 LOG.warning("the terms acceptance could not be recorded", exc_info=True) def _install_backdrop(self): """spaCR's own field at 1.5x, or ``None``. NONE IS A FINE ANSWER (INVARIANTS 10). With no ambient engine available the slides are slides on a plain dialog, and every answer they write is the same. """ try: from .ambient import install_ambient widget = install_ambient(self, theme=BACKDROP_THEME, palette="spacr", speed=BACKDROP_SPEED, corner_radius=CARD_RADIUS) widget.setProperty("spacrSetupBackdrop", True) return widget except Exception: # noqa: BLE001 LOG.debug("no ambient backdrop on this platform", exc_info=True) return None
[docs] def resizeEvent(self, event): # noqa: N802 - Qt naming """Re-lay the slide for the new size. :param event: the Qt resize event. """ super().resizeEvent(event) self.card.setGeometry(self.rect()) self.card.raise_() self._look_at_the_terms_gate() try: from .glass import round_the_corners round_the_corners(self, CARD_RADIUS) except Exception: # noqa: BLE001 LOG.debug("could not round the setup window", exc_info=True) self._place_the_greeting()
def _catalogue_this_screen() -> None: """Put the terms slide's captions in the translation catalogs. Called once when this module is imported, because ``SLIDES`` is what a reader (and the catalog check) looks at and it must already carry its translations by then. """ try: from .. import terms as terms_module terms_module.register_translations() except Exception: # noqa: BLE001 LOG.debug("the terms captions could not be catalogued", exc_info=True) try: from ..i18n import add_translation add_translation(ANIMATION_LABEL, ( "Animation", "Animation", "Animación", "动画", "Animação", "एनिमेशन", "애니메이션", "Hreyfimynd", "Animation")) except Exception: # noqa: BLE001 LOG.debug("the animation caption could not be catalogued", exc_info=True) _catalogue_this_screen()
[docs] def open_setup_if_needed(parent=None) -> Optional[SetupSlides]: """Show the setup slides when the recorded setup state requires them. The centralized :func:`spacr.qt.setup_screen.should_open` check prevents independent callers from opening duplicate dialogs during one launch, and :func:`spacr.qt.terms.needs_agreement` adds the one condition a default cannot satisfy: terms that have never been accepted, or that have been rewritten since they were. """ from ..setup_screen import should_open, skipped_on_purpose from ..terms import needs_agreement if skipped_on_purpose(): return None if not should_open() and not needs_agreement(): return None dialog = SetupSlides(parent) dialog.exec() return dialog