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
import re
from typing import Any, Dict, List, 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
#: assistant and data-sharing choices.
#: 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")),
    ("The assistant",
     "spaCR can explain an error or a result through a coding assistant you "
     "already subscribe to. It uses the vendor's own command-line tool, so "
     "nothing is sent anywhere you have not already logged in to.",
     ("ai_provider", "ai_default")),
    ("When something breaks",
     "What may leave this machine, and under whose name. With issue filing "
     "set to always, the default, a failed run files a redacted report on "
     "the public spaCR GitHub repository automatically, once per error, "
     "under the GitHub account signed in below. With ask, you see each "
     "report first and it is sent only when you press Send. With never, "
     "nothing is sent.",
     ("issue_prompt", "share_logs")),
    ("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": "你好",
}

#: The providers offered as logo buttons, and the CLI each one needs.
#: A DROPDOWN OF THREE NAMES IS A DROPDOWN; three logos is a choice somebody
#: makes in one glance.
PROVIDERS: Tuple[Tuple[str, str, str], ...] = (
    ("claude", "Claude", "claude"),
    ("gpt", "GPT", "codex"),
    ("gemini", "Gemini", "gemini"),
)

#: 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.") #: 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, which is the only sense that matters here: 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)) def _let_go_of(process) -> None: """Detach a still-running `gh` from a dialog that is being destroyed. Its signals are disconnected first: they point at widgets that are about to stop existing, and a `finished` delivered after that is the `libshiboken: Internal C++ object already deleted` crash. """ import warnings for signal in ("finished", "readyReadStandardOutput", "errorOccurred", "readyReadStandardError"): try: with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message=r".*Failed to disconnect.*", category=RuntimeWarning) getattr(process, signal).disconnect() except (RuntimeError, TypeError, AttributeError): pass try: process.setParent(None) except Exception: # noqa: BLE001 LOG.debug("gh process could not be detached", exc_info=True) def _held_at_the_top(label: QLabel) -> QWidget: """``label`` in a cell that may be taller than it is. A form centres a label in its row. Where the field is a stack rather than a single control, centred means level with the seam between its parts; this pins the label to the top so it reads against the first of them. """ holder = QWidget() holder.setAttribute(Qt.WA_TranslucentBackground, True) holder.setStyleSheet("background: transparent;") column = QVBoxLayout(holder) column.setContentsMargins(0, 0, 0, 0) column.setSpacing(0) try: from .provider_marks import ProviderMark label.setMinimumHeight(ProviderMark("codex", "", False).sizeHint() .height()) except Exception: # noqa: BLE001 LOG.debug("the provider mark would not report a height", exc_info=True) column.addWidget(label) column.addStretch(1) return holder
[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("spacrProviderStrip") or 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) signs_in = "issue_prompt" in keys 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) if signs_in: form.addRow(self._github_row()) 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 def _github_row(self) -> QWidget: """Sign in to GitHub, on the slide that decides about issues. THE CLI OWNS THE CREDENTIAL. spaCR never captures or stores a token -- `gh` puts it in the platform credential manager, which is the one place a user can revoke it from and the one place that is not a second copy of a secret. This row says whether a token is reachable and starts `gh auth login` when it is not; it never asks for one. Filing an issue works WITHOUT it, through the browser, which is why this is a row on a slide and not a gate in front of one. Beside the mark, for a user with no GitHub account at all, a button opens GitHub's sign-up page; under it, a :class:`~spacr.qt.widgets.cli_setup_panel.CliSetupPanel` shows an install of the GitHub CLI while it runs. """ holder = QWidget() holder.setProperty("spacrProviderStrip", True) column = QVBoxLayout(holder) column.setContentsMargins(0, 0, 0, 0) column.setSpacing(4) row = QHBoxLayout() row.setContentsMargins(0, 0, 0, 0) row.setSpacing(10) column.addLayout(row) from .cli_setup_panel import CliSetupPanel from .provider_marks import ProviderMark self._gh_mark = ProviderMark("github", "GitHub", False, holder) self._gh_mark.chosen.connect(lambda *_a: self._on_github_mark()) row.addWidget(self._gh_mark) self._gh_signup = QPushButton("Create a GitHub account") self._gh_signup.setToolTip( "Opens GitHub's sign-up page in your browser. Come back here " "afterwards to install the GitHub CLI and sign in.") self._gh_signup.clicked.connect( lambda *_a: self._open_in_the_browser(self.GITHUB_SIGNUP_PAGE)) row.addWidget(self._gh_signup) row.addStretch(1) self._gh_status = QLabel("") self._gh_status.setObjectName("Muted") self._gh_status.setWordWrap(True) row.addWidget(self._gh_status) self._gh_setup = CliSetupPanel( holder, open_page=lambda url: self._open_in_the_browser(url)) self._gh_setup.changed.connect(lambda: self._refresh_github()) column.addWidget(self._gh_setup) self._refresh_github() return holder def _on_github_mark(self) -> bool: """What clicking the logo does. Signed out or signed in, it is `gh auth login` -- signing in again is how you switch account or replace an expired token. With no `gh` on PATH there is nothing to log into, so it offers to install one: :meth:`_offer_github_install` -- unless an install is already running under the row, whose progress and Cancel are on screen. """ if self._gh_action == "install": panel = self._live_panel("_gh_setup") if panel is not None and panel.is_busy(): return False return self._offer_github_install() return self._sign_in_to_github() #: GitHub's page for making an account, opened by the button beside the #: GitHub mark. Making the account happens there, in the browser. GITHUB_SIGNUP_PAGE = "https://github.com/signup" def _offer_github_install(self) -> bool: """Say what installing the GitHub CLI would run, and run it if asked. NOTHING RUNS UNTIL INSTALL IS PRESSED, and the command is in the prompt before it is. When no install method can run here -- no Homebrew and no conda on Linux, say -- the prompt says which program is missing and offers the install page and the command instead. :returns: whether something was started, opened or copied. """ from PySide6.QtWidgets import QApplication, QMessageBox from ..ai import cli_install from ..ai.providers import github_cli tool = github_cli() plan = cli_install.plan_install(tool) panel = self._live_panel("_gh_setup") automatic = plan.automatic and panel is not None box = QMessageBox(self) box.setIcon(QMessageBox.Icon.Information) box.setWindowTitle(_say("Set up the GitHub CLI")) box.setText(_say( "The GitHub CLI is not installed.\n\nWith it, spaCR files a " "report under your GitHub account without opening the browser. " "Without it, reports still open in your browser.")) if automatic: box.setInformativeText(_say( "Install runs:\n {command}\n\nThen GitHub's own sign-in " "starts.", command=plan.command)) elif plan.automatic: box.setInformativeText(f"Install with:\n {plan.command}") else: box.setInformativeText(_say( "spaCR cannot install it here by itself: it needs {needs}, " "and none was found. Install with:\n {command}", needs=plan.needs, command=plan.command)) act_install = (box.addButton(_say("Install"), QMessageBox.ButtonRole.AcceptRole) if automatic else None) act_open = box.addButton( "Open the page", QMessageBox.ButtonRole.ActionRole if automatic else QMessageBox.ButtonRole.AcceptRole) act_copy = box.addButton("Copy the command", QMessageBox.ButtonRole.ActionRole) box.addButton("Later", QMessageBox.ButtonRole.RejectRole) box.exec() clicked = box.clickedButton() if act_install is not None and clicked is act_install: return panel.install( tool, after_install=self._sign_in_after_github_install, page=self.GITHUB_CLI_PAGE) if clicked is act_open: return self._open_in_the_browser(self.GITHUB_CLI_PAGE) if clicked is act_copy: try: QApplication.clipboard().setText(plan.command) except Exception: # noqa: BLE001 LOG.debug("could not copy the command", exc_info=True) return True return False def _sign_in_after_github_install(self) -> bool: """Go straight from a finished GitHub CLI install to its sign-in. The row has already been re-read by then: the panel says ``changed`` before it calls this, and that re-read is what turns the mark's next action from install to sign-in. :returns: whether `gh auth login` started. """ from ..ai.providers import github_cli panel = self._live_panel("_gh_setup") if panel is None: self._refresh_github() return self._sign_in_to_github() return panel.sign_in(github_cli(), self._sign_in_to_github) def _github_sign_in_ended(self, exit_code: int = 0, *_rest) -> None: """Re-read the row when `gh auth login` exits, and say if it failed. :param exit_code: `gh`'s exit status; not zero when the sign-in was cancelled, expired or refused. """ self._refresh_github() if not exit_code or not self._still_on_screen(): return from .cli_setup_panel import SIGNING_IN panel = getattr(self, "_gh_setup", None) if (panel is not None and _still_a_widget(panel) and panel.state == SIGNING_IN): panel.sign_in_failed(_say( "GitHub's sign-in ended before it was finished. Press Sign " "in to try again.")) def _still_on_screen(self) -> bool: """Whether this dialog's widgets are still real C++ objects. ``shiboken6.isValid`` is the only way to ask: a deleted QWidget keeps its Python wrapper, so ``is not None`` says yes right up until the attribute access raises. """ for name in ("_gh_status", "_gh_mark"): widget = getattr(self, name, None) if widget is not None and not _still_a_widget(widget): return False return _still_a_widget(self) #: What each token source is called on screen. GITHUB_SOURCES = { "gh": "signed in through the GitHub CLI", "env": "signed in through GITHUB_TOKEN", "token": "signed in with a stored token", } def _refresh_github(self) -> None: """Say whether a token is reachable, and from where. SAFE AFTER THE SLIDES ARE GONE. `gh auth login` outlives this dialog -- the user finishes in a browser at their own pace -- and its `finished` signal lands here whenever that happens. If the setup screen has been closed by then, its child widgets are deleted C++ objects and touching one raises. Whether `gh` is there is asked through :meth:`spacr.qt.ai.providers.GitHubCli.is_installed`, the question the install panel asks. Whether a token is reachable is :func:`spacr.qt.ai.github_auth.auth_source`, which also knows ``GITHUB_TOKEN`` and a stored token; it runs `gh auth token` here, on the GUI thread, and is asked once each time the row's state may have changed. """ if not self._still_on_screen(): return try: from ..ai import github_auth source = github_auth.auth_source() except Exception: # noqa: BLE001 LOG.debug("GitHub auth is not readable here", exc_info=True) source = None from ..ai.providers import github_cli mark = getattr(self, "_gh_mark", None) signup = getattr(self, "_gh_signup", None) if signup is not None and _still_a_widget(signup): signup.setVisible(not source) if source: if mark is not None: self._light_the_github_mark(mark, mark.READY) self._gh_status.setText( _say(self.GITHUB_SOURCES.get(source, "signed in"))) if mark is not None: mark.setToolTip( f"You are {self.GITHUB_SOURCES.get(source, 'signed in')}." f" Clicking runs `gh auth login` again, which is how you" f" switch to another account or replace a token that has" f" expired.") self._gh_action = "login" return if not github_cli().is_installed(): if mark is not None: self._light_the_github_mark(mark, mark.NOT_INSTALLED) mark.setToolTip( "Offers to install the GitHub CLI and then signs you " "in. Without it, filing an issue still works -- it " "opens in whichever browser you are already signed in " "to.") self._gh_status.setText(_say( "the GitHub CLI is not installed — reports open in your " "browser")) self._gh_action = "install" return if mark is not None: self._light_the_github_mark(mark, mark.SIGNED_OUT) mark.setToolTip( "Runs `gh auth login`, the GitHub CLI's own browser " "sign-in. GitHub stores the credential; spaCR never sees " "it.") self._gh_status.setText( _say("not signed in — reports open in your browser")) self._gh_action = "login" @staticmethod def _light_the_github_mark(mark, status: str) -> None: """Put the mark in ``status`` and repaint it if anything moved. `available` is what decides the brand fill, so it follows READY -- the same rule the AI marks use, where a filled mark means the tool is there and usable and a muted one means it is not. """ available = status == mark.READY if mark.status != status or bool(mark.available) != available: mark.status = status mark.available = available mark.update() #: Where the GitHub CLI's own install instructions are. Offered beside #: Install when `gh` is absent, and instead of it when no install method #: can run here, because "the CLI is not installed" beside a dead button #: tells the user what is wrong and gives them nothing to do about it. GITHUB_CLI_PAGE = "https://cli.github.com/" #: What the button does next: 'login' or 'install'. Set by #: `_refresh_github`, which is the only thing that knows which state the #: machine is in. _gh_action = "login" #: The greeting's fade-out, held so it is not collected mid-animation. _goodbye = None #: Whether the browser has already been opened for THIS sign-in. #: #: `gh` reprints its code as it polls, and opening a tab per line would #: bury the one the user is typing into. _gh_opened = False #: GitHub's device-code page, which is where `gh auth login --web` sends #: you. Opened by spaCR rather than by `gh` -- see `_sign_in_to_github`. GITHUB_DEVICE_PAGE = "https://github.com/login/device" #: The shape of the one-time code `gh` prints before it opens a browser. GH_CODE = re.compile(r"\b([A-Z0-9]{4}-[A-Z0-9]{4})\b") def _sign_in_to_github(self) -> bool: """Start `gh auth login`, or open the install page. True if started. THE BROWSER IS OPENED HERE, and that is the whole point of this method. `gh auth login --web` does open one -- after printing a one-time code and waiting for Enter ON A TERMINAL. Started from a GUI there is no terminal, so `gh` sat forever on a prompt nobody could answer while the dialog said "waiting for GitHub in your browser…" about a browser that never opened. Reported exactly that way. So spaCR reads the code out of `gh`'s output, shows it, opens GitHub's device page itself, and then answers the prompt. The user sees the code they have to type and the page to type it into. DETACHED, and the dialog does not wait: the flow takes as long as the user takes, and a modal setup screen frozen behind it would look crashed. The status re-reads when the process ends. """ from PySide6.QtCore import QProcess if self._gh_action == "install": return self._open_in_the_browser(self.GITHUB_CLI_PAGE) self._gh_opened = False process = QProcess(self) process.setProcessChannelMode(QProcess.MergedChannels) process.readyReadStandardOutput.connect( lambda: self._read_github_output(process)) process.finished.connect( lambda code=0, *_a: self._github_sign_in_ended(code)) self.destroyed.connect(lambda *_a: _let_go_of(process)) try: import shutil process.start(shutil.which("gh") or "gh", ["auth", "login", "--web", "--hostname", "github.com"]) started = process.waitForStarted(3000) except Exception: # noqa: BLE001 LOG.debug("gh auth login would not start", exc_info=True) started = False if not started: self._gh_status.setText(_say( "`gh auth login` would not start — run it in a terminal")) return False self._gh_process = process self._gh_status.setText(_say("starting GitHub sign-in…")) return True def _open_in_the_browser(self, url: str) -> bool: """Open ``url`` in the user's default browser. True if it opened.""" from PySide6.QtCore import QUrl from PySide6.QtGui import QDesktopServices try: return bool(QDesktopServices.openUrl(QUrl(str(url)))) except Exception: # noqa: BLE001 LOG.debug("could not open %r", url, exc_info=True) return False def _read_github_output(self, process) -> str: """Show `gh`'s one-time code, open the device page, answer the prompt. :returns: the code found this time, or "". """ try: chunk = bytes(process.readAllStandardOutput()).decode( "utf-8", "replace") except Exception: # noqa: BLE001 return "" found = self.GH_CODE.search(chunk or "") if found and not self._gh_opened: self._gh_opened = True code = found.group(1) opened = self._open_in_the_browser(self.GITHUB_DEVICE_PAGE) where = ("your browser" if opened else self.GITHUB_DEVICE_PAGE) self._gh_status.setText( _say("enter {code} in {where}", code=code, where=where)) try: process.write(b"\n") except Exception: # noqa: BLE001 LOG.debug("could not answer the gh prompt", exc_info=True) return code return found.group(1) if found else "" 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. THE SCROLL GATE IS GONE. 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. WHAT IS NOT GONE IS THE ACCEPTANCE. 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 if key == "ai_provider": return _held_at_the_top(label), editor return label, editor def _editor(self, key: str, choices, value) -> QWidget: """A logo strip, 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 key == "ai_provider": return self._provider_buttons(value) 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 _provider_buttons(self, value) -> QWidget: """Claude, GPT and Gemini as their MARKS rather than as three words. "the claude gpt and gemeni buttons should be the logos for each not just buttons" -- a mark is recognised before it is read, and this is the one question where the user already knows the answer and only has to point at it. The marks are drawn, not shipped: see :mod:`spacr.qt.widgets.provider_marks`. Under the marks, a :class:`~spacr.qt.widgets.cli_setup_panel.CliSetupPanel` shows an install or a sign-in while it runs, and re-colours the marks when it ends. """ from .cli_setup_panel import CliSetupPanel from .provider_marks import ProviderMark holder = QWidget() holder.setProperty("spacrProviderStrip", True) column = QVBoxLayout(holder) column.setContentsMargins(0, 0, 0, 0) column.setSpacing(4) row = QHBoxLayout() row.setContentsMargins(0, 0, 0, 0) row.setSpacing(10) column.addLayout(row) holder._chosen = str(value or "") holder._buttons = {} holder._note = QLabel("") holder._note.setObjectName("Muted") holder._note.setWordWrap(True) column.addWidget(holder._note) for code, label, command in PROVIDERS: state = self.provider_status(code, command) ready = state == ProviderMark.READY mark = ProviderMark(code, label, ready, holder, status=state) mark.set_chosen(holder._chosen == code) mark.setToolTip(self._mark_tip(label, state)) mark.chosen.connect( lambda picked, h=holder: self._choose_provider(h, picked)) row.addWidget(mark) holder._buttons[code] = mark row.addStretch(1) self._ai_setup = CliSetupPanel( holder, open_page=lambda url: self._open_in_the_browser(url)) self._ai_setup.changed.connect( lambda h=holder: self._refresh_provider_marks(h)) column.addWidget(self._ai_setup) return holder @staticmethod def _provider_is_installed(command: str) -> bool: """Whether the vendor CLI is on PATH. AN UNINSTALLED PROVIDER SAYS SO rather than being offered as though it were ready: choosing it would leave the assistant silently unavailable, and the user would blame spaCR. """ import shutil return shutil.which(str(command)) is not None @staticmethod
[docs] def provider_status(code: str, command: str) -> str: """``ready`` / ``signed out`` / ``not installed`` for one provider. THREE STATES, because they need three different things from the user. `available` was one boolean covering "the CLI is missing" and "the CLI is there and signed out", so a mark could not say which, and both were drawn as a ghost -- "GPT brings no text and no color just a rim". :param code: provider code as in :data:`PROVIDERS`, e.g. ``"claude"`` or ``"gpt"``; looked up in the AI provider registry. :param command: the provider's CLI name, e.g. ``"codex"``; tried in the registry after ``code``, and checked on ``PATH`` when neither resolves. """ from .provider_marks import ProviderMark try: provider = SetupSlides._provider_object(code, command) if provider is not None: if not provider.is_installed(): return ProviderMark.NOT_INSTALLED return (ProviderMark.READY if provider.is_logged_in() else ProviderMark.SIGNED_OUT) except Exception: # noqa: BLE001 LOG.debug("could not ask %r what state it is in", code, exc_info=True) return (ProviderMark.READY if SetupSlides._provider_is_installed(command) else ProviderMark.NOT_INSTALLED)
@staticmethod def _provider_is_signed_in(code: str, command: str) -> bool: """Whether this provider is installed AND logged in. WHAT THE COLOUR MEANS. The mark is drawn in the brand colour when this is true and in muted ink when it is not -- "the icon should be coloured if they are logged in". It used to mean only "the CLI is on PATH", so a provider that was installed and signed out looked identical to one that was ready to answer. """ try: from ..ai.providers import get_provider provider = get_provider(str(code)) if provider is not None: return bool(provider.is_configured()) except Exception: # noqa: BLE001 LOG.debug("could not ask %r whether it is signed in", code, exc_info=True) return SetupSlides._provider_is_installed(command) @staticmethod def _provider_object(code: str, command: str = ""): """The registry entry for ``code``, whichever name it is filed under. THE TWO VOCABULARIES DID NOT MEET. This screen calls OpenAI's provider `gpt` -- which is what a user recognises -- and `ai.providers` files it under `codex`, which is its CLI. So `get_provider('gpt')` found nothing, GPT alone fell through every state check to a bare PATH test, and it was the one mark that rendered as neither installed nor signed in nor anything else. Tried by code first and by CLI name second, so both spellings resolve and neither module has to rename anything. """ try: from ..ai.providers import get_provider except Exception: # noqa: BLE001 return None for name in (str(code), str(command)): if not name: continue try: found = get_provider(name) except Exception: # noqa: BLE001 found = None if found is not None: return found return None #: Where each provider's CLI is installed from. #: #: A PAGE, NOT A COMMAND. The install one-liner differs by operating #: system and by package manager, and running the wrong one is worse #: than opening the page that lists them all -- which is also the only #: step that behaves the same on Linux, macOS and Windows. PROVIDER_PAGES = { "claude": "https://docs.anthropic.com/en/docs/claude-code/setup", "gpt": "https://github.com/openai/codex", "gemini": "https://github.com/google-gemini/gemini-cli", } #: Terminal emulators tried, in order, for an interactive CLI login. #: #: These logins are conversations -- a code to copy, a key to paste -- #: and a GUI child process has no terminal to have them in. Started #: without one they hang on a prompt nobody can see, which is exactly #: how the GitHub button used to fail. TERMINALS: Tuple[Tuple[str, Tuple[str, ...]], ...] = ( ("x-terminal-emulator", ("-e",)), ("gnome-terminal", ("--",)), ("konsole", ("-e",)), ("xfce4-terminal", ("-e",)), ("xterm", ("-e",)), ) def _run_in_a_terminal(self, command: str) -> bool: """Run ``command`` in the user's terminal. True if one was found.""" import shutil import sys from PySide6.QtCore import QProcess parts = str(command).split() if not parts: return False if sys.platform == "darwin": return QProcess.startDetached( "osascript", ["-e", f'tell app "Terminal" to do script ' f'"{command}"']) if sys.platform.startswith("win"): return QProcess.startDetached("cmd", ["/c", "start", *parts]) for terminal, flag in self.TERMINALS: if shutil.which(terminal) is None: continue return bool(QProcess.startDetached(terminal, [*flag, *parts])) return False def _start_provider_login(self, code: str) -> str: """Begin ``code``'s sign-in. Returns what the user should be told. "If the user clicks an AI provider they should get prompted to login right away." Choosing one used to do nothing but tick it, and the login instructions lived in another screen entirely. """ command = next((c for k, _l, c in PROVIDERS if k == code), "") provider = self._provider_object(code, command) if provider is None: return "" if provider.is_installed() and provider.is_logged_in(): return "" return self._prompt_to_set_up(provider, code) def _prompt_to_set_up(self, provider, code: str) -> str: """Ask what to do about a provider that is not ready, and do it. A NOTE UNDER THE ROW WAS NOT ENOUGH -- "there is no popup no prompt for installing or any guidance". Choosing a provider you have not set up is the moment to say what it needs and offer to start it, so this is a dialog with the command in it and buttons that act. INSTALL RUNS THE COMMAND IN THE PROMPT, and nothing runs until it is pressed. Instead of handing the user a curl link, spaCR runs the download and the install itself: the command shown is the provider's own install row for this operating system (:data:`spacr.qt.ai.providers.INSTALL_METHODS`), the install runs off the GUI thread in the panel under the marks, and the sign-in follows it. When no row can run here -- no npm and no Homebrew for GPT, say -- the prompt names what is missing and keeps the page and the command. Every other button works on every operating system: opening a page goes through QDesktopServices, and the terminal launcher knows macOS, Windows and the usual Linux emulators. :returns: a short line for the row's note, or "". """ from PySide6.QtWidgets import QMessageBox from ..ai import cli_install installed = provider.is_installed() page = self.PROVIDER_PAGES.get(str(code), "") hint = str(getattr(provider, "install_hint", "") or "") login = str(getattr(provider, "login_command", "") or "") plan = None if installed else cli_install.plan_install(provider) can_install = (plan is not None and plan.automatic and self._live_panel("_ai_setup") is not None) box = QMessageBox(self) box.setIcon(QMessageBox.Icon.Information) box.setWindowTitle(f"Set up {provider.label}") if can_install: box.setText(_say( "{label} is not installed yet.\n\nInstall runs the command " "below on this computer: it downloads the vendor's own " "installer and runs it, then starts the sign-in. spaCR never " "sees your credentials.", label=provider.label)) box.setInformativeText(_say("Install runs:\n {command}", command=plan.command)) elif not installed: box.setText( f"{provider.label} is not installed yet.\n\n" f"spaCR drives the vendor's own command-line tool, so " f"installing `{provider.cli_name}` is all that is needed — " f"spaCR never sees your credentials.") informative = f"Install with:\n {hint}" if hint else "" if plan is not None and not plan.automatic and plan.needs: informative += "\n\n" + _say( "spaCR cannot run this itself here: it needs {needs}, " "and none was found.", needs=plan.needs) box.setInformativeText(informative) else: box.setText( f"{provider.label} is installed but not signed in.\n\n" f"Signing in happens in the vendor's own tool; spaCR never " f"sees the credential.") box.setInformativeText(f"Sign in with:\n {login}" if login else "") act_install = (box.addButton(_say("Install"), QMessageBox.ButtonRole.AcceptRole) if can_install else None) act_open = (box.addButton( "Open the page", QMessageBox.ButtonRole.ActionRole if can_install else QMessageBox.ButtonRole.AcceptRole) if (page and not installed) else None) act_run = (box.addButton("Sign in now", QMessageBox.ButtonRole.AcceptRole) if (installed and login) else None) act_copy = box.addButton("Copy the command", QMessageBox.ButtonRole.ActionRole) box.addButton("Later", QMessageBox.ButtonRole.RejectRole) box.exec() clicked = box.clickedButton() if act_install is not None and clicked is act_install: if self._install_provider(provider, code): return "" panel = self._live_panel("_ai_setup") if panel is not None and panel.is_busy(): return _say("Another install is still running. Wait for it " "to finish, or press Cancel under the marks.") return f"{provider.label} is not set up yet." if act_open is not None and clicked is act_open: self._open_in_the_browser(page) return f"{provider.label}: its install page is open in your browser." if act_run is not None and clicked is act_run: if self._sign_in_to_provider(provider, login): return _say("Signing in to {label}…", label=provider.label) return f"Run `{login}` to sign in to {provider.label}." if clicked is act_copy: command = login if installed else ( plan.command if can_install else hint) try: from PySide6.QtWidgets import QApplication QApplication.clipboard().setText(command) except Exception: # noqa: BLE001 LOG.debug("could not copy the command", exc_info=True) return f"Copied: {command}" return (f"{provider.label} is not set up yet." if not installed else f"{provider.label} is installed but not signed in.") def _live_panel(self, name: str): """The install panel called ``name``, if it still exists. :param name: ``"_ai_setup"`` or ``"_gh_setup"``. :returns: the panel, or ``None`` when it was never built or its C++ object has gone. """ panel = getattr(self, name, None) if panel is None or not _still_a_widget(panel): return None return panel def _install_provider(self, provider, code: str) -> bool: """Run ``provider``'s installer in the panel, then its sign-in. :param provider: the provider the user chose. :param code: this screen's name for it, for its install page. :returns: whether the installer started. """ import functools panel = self._live_panel("_ai_setup") if panel is None: return False login = str(getattr(provider, "login_command", "") or "") return panel.install( provider, page=self.PROVIDER_PAGES.get(str(code), ""), after_install=functools.partial(self._sign_in_to_provider, provider, login)) def _sign_in_to_provider(self, provider, login: str) -> bool: """Start ``login`` and have the panel watch it. The vendors' sign-ins are conversations -- a code to copy, a browser to confirm in -- so they run in the embedded terminal or the platform fallback. The panel polls the provider's status until it says yes. :param provider: the provider to sign in to. :param login: its sign-in command. :returns: whether the sign-in was started. """ panel = self._live_panel("_ai_setup") if panel is None: return bool(self._sign_in_here_or_in_a_terminal(provider, login)) return panel.sign_in( provider, lambda: self._sign_in_here_or_in_a_terminal(provider, login)) def _sign_in_here_or_in_a_terminal(self, provider, login: str) -> bool: """Hold the sign-in inside spaCR, or hand it to a terminal. The goal is that no terminal is used. On Linux and macOS the sign-in runs on a pseudo-terminal in a small spaCR window (:class:`spacr.qt.ai.pty_sign_in.SignInDialog`), which opens the sign-in page and passes any code back; the panel's status polling notices the sign-in as before. Windows, which has no pseudo-terminal in the standard library, still opens a terminal. :param provider: the provider being signed in to. :param login: its sign-in command. :returns: whether the sign-in was started. """ from ..ai.pty_sign_in import SignInDialog, pty_available self._stop_sign_in() argv = str(login).split() if not argv or not pty_available(): return bool(self._run_in_a_terminal(login)) try: dialog = SignInDialog(str(getattr(provider, "label", "") or argv[0]), argv, self) except Exception: # noqa: BLE001 LOG.debug("could not sign in inside spaCR", exc_info=True) return bool(self._run_in_a_terminal(login)) self._sign_in_dialog = dialog dialog.show() return True def _stop_sign_in(self) -> None: """Close the embedded sign-in before replacing it or leaving setup.""" dialog = getattr(self, "_sign_in_dialog", None) self._sign_in_dialog = None if dialog is not None: try: dialog.close() except RuntimeError: LOG.debug("the sign-in window was already destroyed", exc_info=True) def _stop_the_installs(self) -> None: """Stop any installer or sign-in watch the panels are running. Called on the way out: an installer left running behind a closed screen is one nobody can cancel any more. """ self._stop_sign_in() for name in ("_ai_setup", "_gh_setup"): panel = self._live_panel(name) if panel is None: continue try: panel.shutdown() except Exception: # noqa: BLE001 LOG.debug("an install panel would not stop", exc_info=True) def _running_installs(self) -> List[str]: """The tools whose installer is running under this screen's rows. :returns: their names as the user reads them, such as ``["Claude"]``; empty when nothing is being installed. """ names: List[str] = [] for name in ("_ai_setup", "_gh_setup"): panel = self._live_panel(name) if panel is not None and panel.is_busy(): names.append(panel.tool_label()) return names def _may_close(self) -> bool: """Whether the screen may close, asking first if an install runs. Closing the screen stops a running installer, and the Install button lives on this screen, so a silent stop would throw the install away with nothing on screen to say so. When an installer is running the user chooses: Keep installing (the default, and what Escape does) leaves the screen open; Stop it and close lets it close. :returns: ``True`` when nothing is being installed or the user chose to stop it; ``False`` when the screen should stay open. """ running = self._running_installs() if not running: return True from PySide6.QtWidgets import QMessageBox box = QMessageBox(self) box.setIcon(QMessageBox.Icon.Warning) box.setWindowTitle(_say("An install is still running")) box.setText(_say( "The installer for {tools} is still running. Closing this screen " "now stops it.", tools=", ".join(running))) box.setInformativeText(_say( "Keep the screen open until it finishes, or stop it and install " "later from Help > Set spaCR up again…")) keep = box.addButton(_say("Keep installing"), QMessageBox.ButtonRole.RejectRole) stop = box.addButton(_say("Stop it and close"), QMessageBox.ButtonRole.DestructiveRole) box.setDefaultButton(keep) box.setEscapeButton(keep) box.exec() return box.clickedButton() is stop def _choose_provider(self, holder, code: str) -> None: """Select a provider AND start its login if it needs one.""" holder._chosen = str(code) for name, mark in holder._buttons.items(): mark.set_chosen(name == code) note = self._start_provider_login(code) status = getattr(holder, "_note", None) if status is not None: status.setText(note) self._refresh_provider_marks(holder) def _refresh_provider_marks(self, holder) -> None: """Re-colour every mark from its CURRENT sign-in state.""" from .provider_marks import ProviderMark for code, label, command in PROVIDERS: mark = getattr(holder, "_buttons", {}).get(code) if mark is None: continue state = self.provider_status(code, command) signed_in = state == ProviderMark.READY if mark.status != state or bool(mark.available) != signed_in: mark.status = state mark.available = signed_in mark.update() mark.setToolTip(self._mark_tip(label, state)) @staticmethod def _mark_tip(label: str, state: str) -> str: """What hovering a provider's mark says, for the state it is in. :param label: the provider's name, such as ``"Claude"``. :param state: the mark's state, from :meth:`provider_status`. :returns: the tooltip. A provider that is not installed says that choosing it offers the install, because that is what it does. """ from .provider_marks import ProviderMark if state == ProviderMark.READY: return f"Use {label}. You are signed in." if state == ProviderMark.NOT_INSTALLED: return (f"Use {label}. Choosing it offers to install it and then " f"starts the sign-in; spaCR drives the vendor's own CLI " f"and never sees the credential.") return (f"Use {label}. Choosing it starts the sign-in; spaCR drives " f"the vendor's own CLI and never sees the credential.") 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_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. "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 choice took, and it lives on the page being left, so without a pause it is 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 IS THE MESSAGE: it tells the user which way they went, which is worth more than the animation. """ 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() elif hasattr(editor, "_chosen"): out[key] = editor._chosen 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. While an installer is running the user is asked first (:meth:`_may_close`): keeping it running leaves the screen open, and stopping it closes the screen as before. """ from ..setup_screen import apply, current_version, mark_answered if not self._may_close(): return self._stop_the_installs() 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. While an installer is running the user is asked first, as :meth:`accept` asks; keeping it running leaves the screen open. """ from ..setup_screen import apply, current_version, mark_answered if not self._may_close(): return self._stop_the_installs() 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