Source code for spacr.qt.widgets.availability_panel

"""Explain unavailable options and offer an applicable next step.

For an unavailable entry this module states why, links the documentation and --
where this environment allows it -- offers to make the entry available. The
code is shared because the regression backend picker and the Image UMAP's GPU
acceleration need the same panel, and a second interactive tooltip would need
its own hover, focus and dismissal handling.

Why this is not a ``QToolTip``: PySide6 reports::

    QToolTip has a linkActivated signal:  False
    QToolTip is a QWidget:                False

``QToolTip`` only paints. It renders rich text and nothing inside it can be
clicked, hovered or focused, so a link drawn in it cannot be pressed. What
works is a frameless tooltip-styled ``QLabel`` with
``Qt.TextBrowserInteraction``: its ``linkActivated`` carries the href, so
``api`` opens the documentation and ``install`` opens the offer.

The row itself stays disabled. ``QStandardItem.setEnabled(False)`` leaves
``ItemIsSelectable`` set -- Qt will not activate a disabled row, but a
model-level selection can still land on it -- so a caller must clear that flag
too. :func:`disable_combo_row` does both and is what the callers use.

An interactive tooltip has three requirements, handled here as follows:

* It must not disappear while the pointer moves toward it. The pointer has to
  cross the gap between the row and the panel, and a plain leave-event
  dismissal closes it mid-journey, which makes the Install link unreachable.
  The hide is a timer, the timer is cancelled on entry, and while it is pending
  the cursor is checked against the corridor -- the union of the anchor's
  rectangle and the panel's -- so travelling through the gap re-arms the timer
  instead of firing it. See :meth:`AvailabilityPanel. _maybe_hide`.
* It must be dismissable. Escape, a click anywhere outside it, and moving well
  clear of the corridor all close it.
* It must be reachable by keyboard, and that cannot be inherited from the row
  because the row is disabled. :meth:`AvailabilityPanel.open_for` is the
  explicit keyboard route: it shows the panel activated with the first link
  focused, Up/Down move between the unavailable entries, Escape closes it and
  returns focus to wherever it came from. A keyboard-opened panel is pinned and
  never closes on a hover timer.

Pressing Install runs :func:`run_install_offer`, which has three outcomes --
see :class:`spacr.updater.InstallOffer`. The dry-run report is shown before
anything is installed, and a plan that would move numpy, torch, pandas or
scikit-learn is refused by default and needs a second confirmation naming what
moves.
"""
from __future__ import annotations

import logging
import time
from html import escape
from typing import Any, Callable, Dict, List, Optional

from PySide6.QtCore import QEvent, QPoint, QRect, QTimer, QUrl, Qt, Signal
from PySide6.QtGui import QCursor, QDesktopServices, QGuiApplication
from PySide6.QtWidgets import (QApplication, QFrame, QHBoxLayout, QLabel,
                               QMessageBox, QVBoxLayout, QWidget)

from ..theme import SPACING, active_palette, font_px

LOGGER = logging.getLogger(__name__)

__all__ = [
    "AvailabilityPanel", "disable_combo_row", "run_install_offer",
    "install_word_for", "explain",
]


#: The word in the slot to the RIGHT of the API link. "Install" is only
#: written where pressing it can install something here; the other two say
#: what the press will actually do, because a button labelled Install that
#: cannot install is the inert control this whole design exists to avoid.
_INSTALL_WORDS = {
    "install": "Install",
    "elsewhere": "How to get it",
    "impossible": "What it needs",
    "ready": "",
}


[docs] def install_word_for(action) -> str: """The link word for an offer's ``action``; ``''`` when there is none. :param action: the offer's action, ``"install"``, ``"elsewhere"``, ``"impossible"`` or ``"ready"`` (which gives ``''``); any other value gives ``"What it needs"``. """ return _INSTALL_WORDS.get(str(action), "What it needs")
[docs] def disable_combo_row(combo, index: int, *, tooltip: str = "") -> None: """Disable a combo row and remove it from keyboard selection. ``QStandardItem.setEnabled(False)`` leaves ``Qt.ItemIsSelectable`` set in the item's flags. Qt refuses to activate a disabled row from the popup, so the mouse route is closed, but a model-level selection (``setCurrentIndex``, a settings CSV round-trip, a view's own selection model) can still land on it. An unavailable entry must not become the selected value, so this function clears both flags. A disabled item retains its ``Qt.ToolTipRole``, so the explanatory tooltip remains available on hover. :param combo: ``QComboBox`` backed by a ``QStandardItemModel``. :param index: Row to disable. :param tooltip: Explanatory tooltip; an empty string leaves it unchanged. """ model = combo.model() item = model.item(index) if hasattr(model, "item") else None if item is None: return item.setEnabled(False) item.setFlags(item.flags() & ~Qt.ItemIsSelectable) if tooltip: item.setToolTip(str(tooltip))
[docs] class AvailabilityPanel(QFrame): """The hover panel a greyed-out option opens: reason, API, Install. Access through :meth:`instance` -- it is a process-wide singleton, so one panel serves every control that has an unavailable option and there is only ever one of them on screen. :signal api_requested: the API link was pressed; carries the URL. :signal install_requested: the Install link was pressed; carries the :class:`spacr.updater.InstallOffer` of the entry on screen. :signal dismissed: the panel closed by Escape, by a click away, or by the pointer leaving the corridor. """ api_requested = Signal(str) install_requested = Signal(object) dismissed = Signal() _INSTANCE: Optional["AvailabilityPanel"] = None #: Width of the prose column. Wide enough for the cuML paragraph without #: turning into a document; the recipes go in the dialog, not here. TEXT_WIDTH = 420 #: How long the panel waits before closing once the pointer leaves. The #: gap between a combo popup row and the panel is a few pixels of travel; #: 250 ms is the same delay `HoverTooltip` settled on. HIDE_DELAY_MS = 250 #: How long the corridor keeps re-arming the timer. A pointer that is #: inside the union of anchor and panel is assumed to be travelling #: TOWARD the panel -- but only for this long, so a pointer that stops #: dead inside the corridor still lets the panel go. CORRIDOR_GRACE_MS = 2500 def __init__(self) -> None: """Build the availability popup. A ``Qt.Tool`` window rather than a ``Qt.ToolTip`` one: a tooltip window can never take focus, and the keyboard route into this panel needs it to. The two link words are separate labels so that "Install sits to the right of the API link" is a fact about geometry a test can measure, rather than a claim about a string. """ super().__init__(None, Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.NoDropShadowWindowHint) self.setObjectName("AvailabilityPanel") self.setFocusPolicy(Qt.StrongFocus) self._title = QLabel(self) self._title.setObjectName("AvailabilityPanelTitle") self._title.setWordWrap(True) self._title.setMaximumWidth(self.TEXT_WIDTH) self._body = QLabel(self) self._body.setObjectName("AvailabilityPanelBody") self._body.setWordWrap(True) self._body.setTextFormat(Qt.RichText) self._body.setMaximumWidth(self.TEXT_WIDTH) self._body.setAlignment(Qt.AlignLeft | Qt.AlignTop) self._links = QWidget(self) self._links.setObjectName("AvailabilityPanelLinks") row = QHBoxLayout(self._links) row.setContentsMargins(0, 0, 0, 0) row.setSpacing(SPACING["md"]) self._api_link = self._make_link("AvailabilityPanelApiLink") self._install_link = self._make_link("AvailabilityPanelInstallLink") row.addWidget(self._api_link) row.addWidget(self._install_link) row.addStretch(1) column = QVBoxLayout(self) column.setContentsMargins(SPACING["sm"], SPACING["xs"], SPACING["sm"], SPACING["xs"]) column.setSpacing(SPACING["xs"]) column.addWidget(self._title) column.addWidget(self._body) column.addWidget(self._links) from ..tooltip_policy import HoverDelay self._hover_delay = HoverDelay(self) self._hover_delay.invalidated.connect(self._cancel_hover) self._hover_identity = None self._hide_timer = QTimer(self) self._hide_timer.setSingleShot(True) self._hide_timer.timeout.connect(self._maybe_hide) self._hide_since = 0.0 self._anchor: Optional[QWidget] = None self._anchor_rect: Optional[QRect] = None self._entries: List[Dict[str, Any]] = [] self._index = 0 self._pinned = False self._return_focus: Optional[QWidget] = None self._filtering = False self._install_handler: Optional[Callable[[Any], None]] = None self._apply_theme()
[docs] def set_install_handler(self, slot) -> None: """Make ``slot`` the only receiver of :attr:`install_requested`. The panel is a process-wide singleton with two callers. A plain ``connect`` on every show accumulates receivers, so one press would eventually run the regression picker's install and the Image UMAP's; a plain ``disconnect()`` warns when nothing is connected yet. Holding the current slot and replacing it does neither. A previous slot bound to a widget that has since been deleted is simply dropped. :param slot: callable taking the current entry's ``offer``, or ``None`` to leave the signal with no receiver. """ previous = self._install_handler if previous is not None: try: self.install_requested.disconnect(previous) except (RuntimeError, TypeError, SystemError): pass self._install_handler = slot if slot is not None: self.install_requested.connect(slot)
def _make_link(self, name: str) -> QLabel: """Build one clickable link label. ``TextBrowserInteraction`` is the measured route: it includes ``LinksAccessibleByKeyboard``, which is what puts the word in the tab order once the panel has focus, and ``linkActivated`` carries the href. :param name: object name, so the theme and the tests can find it. :returns: the label. """ label = QLabel(self) label.setObjectName(name) label.setTextFormat(Qt.RichText) label.setTextInteractionFlags(Qt.TextBrowserInteraction) label.linkActivated.connect(self._on_link) return label @classmethod
[docs] def instance(cls) -> "AvailabilityPanel": """The process-wide panel, created on first use.""" if cls._INSTANCE is None: cls._INSTANCE = AvailabilityPanel() return cls._INSTANCE
[docs] def entries(self) -> List[Dict[str, Any]]: """The entries this panel is currently cycling through.""" return list(self._entries)
[docs] def current_entry(self) -> Optional[Dict[str, Any]]: """The entry on screen, or ``None`` when nothing is shown.""" if not self._entries: return None return self._entries[self._index]
[docs] def current_offer(self): """The :class:`spacr.updater.InstallOffer` on screen, or ``None``.""" entry = self.current_entry() return None if entry is None else entry.get('offer')
[docs] def body_label(self) -> QLabel: """The explanation label.""" return self._body
[docs] def is_pinned(self) -> bool: """Whether the panel was opened by keyboard. A pinned panel ignores hover timers.""" return self._pinned
[docs] def show_for(self, anchor: QWidget, entries, index: int = 0, *, anchor_rect: Optional[QRect] = None, pinned: bool = False, immediate: bool = False) -> None: """Show the panel for ``entries[index]``, docked under ``anchor``. :param anchor: the widget the panel belongs to. Used for placement and as one half of the corridor the pointer may cross. :param entries: mappings as :func:`spacr.regression_backends.availability_entry` returns -- ``{title, reason, url, offer}``. More than one makes the panel cyclable with Up/Down when it is pinned. :param index: which of them to show. :param anchor_rect: the anchor's rectangle in GLOBAL coordinates, when the thing being explained is smaller than the widget -- a single row of an open combo popup, for instance. :param pinned: opened by keyboard. See :meth:`open_for`. :param immediate: explicit click/keyboard request, bypassing hover delay. """ items = [dict(entry) for entry in (entries or [])] if not items: return identity = (anchor, int(index), repr(items), (anchor_rect.x(), anchor_rect.y(), anchor_rect.width(), anchor_rect.height()) if anchor_rect else None) if not (immediate or pinned): if identity == self._hover_identity and ( self._hover_delay._timer.isActive() or self.isVisible()): return self.hide() self._hover_identity = identity self._hover_delay.schedule(anchor, lambda: self.show_for( anchor, items, index, anchor_rect=anchor_rect, immediate=True)) return self._hover_delay.cancel() self._hover_identity = identity self._entries = items self._index = max(0, min(int(index), len(items) - 1)) self._anchor = anchor self._anchor_rect = QRect(anchor_rect) if anchor_rect else None self._pinned = bool(pinned) self._hide_timer.stop() self._apply_theme() self._render() self.adjustSize() self._position() self.setAttribute(Qt.WA_ShowWithoutActivating, not self._pinned) self.show() self._install_filter()
[docs] def open_for(self, anchor: QWidget, entries, index: int = 0, *, anchor_rect: Optional[QRect] = None) -> None: """The keyboard route: show the panel pinned, activated and focused. The row that would normally carry this help is disabled, so it cannot be tabbed to and nothing can be inherited from it. A caller wires this to a key on the control that is focusable -- the combo itself -- and the panel takes it from there: Tab moves between **API** and **Install**, Enter presses one, Up/Down move to the next unavailable entry, Escape closes and hands focus back. :param anchor: the widget the panel is docked under; see :meth:`show_for`. :param entries: availability mappings (``{title, reason, url, offer}``) as :meth:`show_for` takes them; an empty list shows nothing. """ self._return_focus = QApplication.focusWidget() self.show_for(anchor, entries, index, anchor_rect=anchor_rect, pinned=True) self.raise_() self.activateWindow() self._api_link.setFocus(Qt.TabFocusReason)
[docs] def show_entry(self, index: int) -> None: """Move to another of the entries without moving the panel. :param index: entry position; wraps around modulo the number of entries, so ``-1`` is the last one. """ if not self._entries: return self._index = int(index) % len(self._entries) self._render() self.adjustSize() self._position()
def _render(self) -> None: """Write the current entry: the refusal first, then what would fix it. Two sentences saying the same thing are collapsed into one -- the status and the install offer genuinely agree on some entries. While pinned with more than one entry the title also carries a position counter, so it is clear there is something to arrow through. """ entry = self.current_entry() or {} offer = entry.get('offer') action = getattr(offer, 'action', 'impossible') title = str(entry.get('title') or "") reason = str(entry.get('reason') or "") message = str(getattr(offer, 'message', "") or "") self._title.setText(f"<b>{escape(title)}</b>") parts = [reason] if message and message.strip() != reason.strip(): parts.append(message) self._body.setText("<br><br>".join( escape(part).replace("\n", "<br>") for part in parts if part)) url = str(entry.get('url') or "") self._api_link.setVisible(bool(url)) self._api_link.setText('<a href="api">API</a>') word = install_word_for(action) self._install_link.setVisible(bool(word)) if word: self._install_link.setText(f'<a href="install">{escape(word)}</a>') counter = "" if self._pinned and len(self._entries) > 1: counter = (f" <span>{self._index + 1}/{len(self._entries)}" f" &middot; Up/Down</span>") if counter: self._title.setText(f"<b>{escape(title)}</b>{counter}") def _position(self) -> None: """Dock under the anchor, clamped to the screen it is on.""" rect = self._anchor_global_rect() if rect is None: return point = QPoint(rect.left(), rect.bottom() + 2) screen = QGuiApplication.screenAt(rect.center()) if screen is None: screen = QGuiApplication.primaryScreen() if screen is not None: available = screen.availableGeometry() size = self.sizeHint() x = min(max(point.x(), available.left()), max(available.right() - size.width(), available.left())) y = point.y() if y + size.height() > available.bottom(): y = max(available.top(), rect.top() - size.height() - 2) point = QPoint(x, y) self.move(point) def _anchor_global_rect(self) -> Optional[QRect]: """Return the anchor's rectangle in global coordinates. :returns: the explicitly set rectangle if there is one, otherwise the anchor widget's; ``None`` when there is no anchor, or when the widget has been destroyed -- in which case the anchor is forgotten rather than raising on the next look. """ if self._anchor_rect is not None: return QRect(self._anchor_rect) anchor = self._anchor if anchor is None: return None try: top_left = anchor.mapToGlobal(QPoint(0, 0)) except RuntimeError: self._anchor = None return None return QRect(top_left, anchor.size()) def _cancel_hover(self) -> None: """Cancel hover help when preferences change; keep explicit help.""" if not self._pinned: self.hide()
[docs] def start_hide(self, delay_ms: Optional[int] = None) -> None: """Schedule the hide the pointer is allowed to interrupt.""" self._hover_delay.cancel() if self._pinned: return if not self._hide_timer.isActive(): self._hide_since = time.monotonic() self._hide_timer.start(int(self.HIDE_DELAY_MS if delay_ms is None else delay_ms))
[docs] def cancel_hide(self) -> None: """Stop a pending hide (the pointer came back).""" self._hide_timer.stop()
[docs] def corridor(self) -> Optional[QRect]: """The rectangle the pointer is allowed to be inside while travelling. The union of the anchor's rectangle and the panel's, the region a pointer moving from one to the other passes through. Hiding on the anchor's leave event would close the panel before the pointer reaches it. """ rect = self._anchor_global_rect() if rect is None: return self.geometry() if self.isVisible() else None return rect.united(self.geometry())
def _cursor_pos(self) -> QPoint: """Where the pointer is, in global coordinates. A method rather than a bare :func:`QCursor.pos` call so the corridor can be driven in a test: the offscreen platform plugin has no real pointer to move, and the gap-crossing rule is precisely the one that has to be checked rather than assumed. """ return QCursor.pos() def _maybe_hide(self) -> None: """Close the panel, unless the pointer is still on its way here. A pinned panel never closes on its own. Otherwise the cursor is checked against the panel, the anchor, and the corridor between them: while it is still travelling the timer is re-armed rather than the panel closed, or the Install link could never be reached. """ if self._pinned: return if self.underMouse(): return anchor = self._anchor if anchor is not None: try: if anchor.underMouse(): return except RuntimeError: self._anchor = None corridor = self.corridor() elapsed_ms = (time.monotonic() - self._hide_since) * 1000.0 if (corridor is not None and elapsed_ms < self.CORRIDOR_GRACE_MS and corridor.contains(self._cursor_pos())): self._hide_timer.start(int(self.HIDE_DELAY_MS)) return self.dismiss()
[docs] def dismiss(self) -> None: """Close the panel and hand focus back where it came from.""" self._hide_timer.stop() self._pinned = False was_visible = self.isVisible() self.hide() self._remove_filter() target, self._return_focus = self._return_focus, None if target is not None: try: target.setFocus(Qt.OtherFocusReason) except RuntimeError: pass if was_visible: self.dismissed.emit()
[docs] def enterEvent(self, event): """The pointer arrived: the panel stays. :param event: the enter event, passed on to the base class after any pending hide is cancelled. """ self.cancel_hide() super().enterEvent(event)
[docs] def leaveEvent(self, event): """The pointer left: start the interruptible hide. :param event: the leave event, passed on to the base class after a 100 ms hide is scheduled. """ self.start_hide(100) super().leaveEvent(event)
[docs] def keyPressEvent(self, event): """Escape closes; Up/Down move between the entries. :param event: the key event; Escape, Up/Left and Down/Right are accepted here (the arrows only with more than one entry), and any other key goes to the base class. """ key = event.key() if key == Qt.Key_Escape: self.dismiss() event.accept() return if key in (Qt.Key_Down, Qt.Key_Right) and len(self._entries) > 1: self.show_entry(self._index + 1) event.accept() return if key in (Qt.Key_Up, Qt.Key_Left) and len(self._entries) > 1: self.show_entry(self._index - 1) event.accept() return super().keyPressEvent(event)
def _install_filter(self) -> None: """Start watching the application for events, if not already watching.""" app = QApplication.instance() if app is not None and not self._filtering: from ..gil_priority import _watch_application_events _watch_application_events(app, self, (QEvent.MouseButtonPress,)) self._filtering = True def _remove_filter(self) -> None: """Stop watching the application for events.""" app = QApplication.instance() if app is not None and self._filtering: from ..gil_priority import _stop_watching_application_events _stop_watching_application_events(app, self) self._filtering = False
[docs] def eventFilter(self, obj, event): """A press anywhere outside the panel dismisses it. :param obj: the watched object; the filter is installed on the application, so this is whatever received the event. Not read. :param event: the event; only a mouse press while the panel is visible is acted on, using its global position. Always returns ``False`` so the press still reaches its target. """ if event.type() == QEvent.MouseButtonPress and self.isVisible(): try: inside = self.geometry().contains(event.globalPosition() .toPoint()) except AttributeError: inside = self.geometry().contains(self._cursor_pos()) if not inside: self.dismiss() return False
[docs] def hideEvent(self, event): """Drop the app filter whenever the panel leaves the screen. :param event: the hide event, passed on to the base class. """ self._hover_delay.cancel() self._remove_filter() super().hideEvent(event)
def _on_link(self, href: str) -> None: """``api`` and ``install`` -- the two hrefs this panel understands.""" entry = self.current_entry() or {} target = str(href or "").strip().lower() if target == "api": url = str(entry.get('url') or "") if url: self.api_requested.emit(url) QDesktopServices.openUrl(QUrl(url)) return if target == "install": offer = entry.get('offer') self._pinned = True self.cancel_hide() self.install_requested.emit(offer) return LOGGER.debug("AvailabilityPanel ignored href %r", href) def _apply_theme(self) -> None: """Tooltip styling, inline, because this is a separate top-level.""" palette = active_palette() self.setStyleSheet( f"QFrame#AvailabilityPanel {{" f" background-color: {palette['surface_alt']};" f" border: 1px solid {palette['border']};" f" border-radius: 6px;" f"}}" f"QWidget#AvailabilityPanelLinks {{ background: transparent; }}" f"QLabel {{" f" color: {palette['fg']};" f" font-size: {font_px('small')}px;" f" background: transparent;" f"}}" f"QLabel#AvailabilityPanelApiLink," f"QLabel#AvailabilityPanelInstallLink {{" f" color: {palette['accent']};" f" text-decoration: none;" f"}}" )
[docs] def run_install_offer(parent, offer, *, confirm=None, inform=None, dry_run=None, install=None) -> str: """Handle the three outcomes of an install offer in one place. 1. Installable here -- the dry-run report is shown first, in full, and the install runs only on confirmation. A plan that would move numpy, torch, pandas or scikit-learn is refused by default and needs a second confirmation naming what moves. 2. Not here, but possible elsewhere -- the environment that would take it is described and nothing is run. 3. Not possible -- stated, with the recipe, and nothing is run. Every side effect is an injected callable so the whole flow can be driven without a screen: ``tests/qt/conftest.py`` makes a static modal raise on purpose. :param parent: the widget dialogs are parented to. :param offer: a :class:`spacr.updater.InstallOffer`. :param confirm: ``(title, text) -> bool``. Defaults to a ``QMessageBox.question`` whose default button is **No**. :param inform: ``(title, text) -> None``. Defaults to ``QMessageBox.information``. :param dry_run: ``(requirement) -> DryRun``. Defaults to :func:`spacr.updater.dry_run_install` behind a progress dialog -- the resolver talks to the network and has been measured taking minutes. :param install: ``(command) -> (code, output)``. Defaults to :func:`spacr.updater.run_install_command`. :returns: one of ``'ready'``, ``'explained'``, ``'declined'``, ``'refused'``, ``'installed'``, ``'failed'``. """ from ...updater import dry_run_install, install_decision, \ run_install_command confirm = confirm or _default_confirm(parent) inform = inform or _default_inform(parent) dry_run = dry_run or _default_dry_run(parent) install = install or run_install_command action = str(getattr(offer, 'action', 'impossible')) title = str(getattr(offer, 'title', 'spaCR')) if action == "ready": inform(title, getattr(offer, 'message', "This is already available.")) return "ready" if action in ("elsewhere", "impossible"): inform(title, offer.as_text()) return "explained" requirement = str(getattr(offer, 'requirement', "") or "") if not requirement: inform(title, offer.as_text()) return "explained" report = dry_run(requirement) decision = install_decision(report) if not decision['allowed']: inform(f"{title} -- not installed", decision['headline'] + "\n\nNothing has been installed. Run the command yourself " "to see the full output:\n " + " ".join(str(part) for part in (offer.command or []))) return "refused" command = " ".join(str(part) for part in (offer.command or [])) if not confirm(f"Install {requirement}?", decision['report'] + "\n\n" + command): return "declined" if decision['needs_second_confirmation']: if not confirm("This moves packages spaCR depends on", decision['headline'] + "\n\n" + command): return "refused" code, output = install(offer.command) if int(code or 0) != 0: inform(f"{title} -- install failed", (output or "").strip()[-4000:] or "The packaging tool failed and said nothing.") return "failed" inform(f"{title} -- restart spaCR", f"{requirement} is installed. RESTART spaCR before using it: pip " f"can upgrade packages underneath a process that has already " f"imported them, and this one has.") return "installed"
def _default_confirm(parent) -> Callable[[str, str], bool]: """Build the default yes/no asker, a modal message box. Injectable through the panel so a test can answer without a dialog. :param parent: the widget to parent the box to. :returns: a callable taking a title and text and returning the answer; it defaults to NO, so a dismissed box never reads as consent. """ def _confirm(title: str, text: str) -> bool: """Ask a yes/no question in a modal box.""" answer = QMessageBox.question( parent, str(title), str(text), QMessageBox.Yes | QMessageBox.No, QMessageBox.No) return answer == QMessageBox.Yes return _confirm def _default_inform(parent) -> Callable[[str, str], None]: """Build the default notifier, a modal message box. :param parent: the widget to parent the box to. :returns: a callable taking a title and text. """ def _inform(title: str, text: str) -> None: """Say something in a modal box.""" QMessageBox.information(parent, str(title), str(text)) return _inform def _default_dry_run(parent) -> Callable[[str], Any]: """:func:`spacr.updater.dry_run_install`, off the GUI thread. The resolver downloads metadata for every candidate; against ``cuml-cu12`` it took minutes on this machine. Run inline it freezes the window, and a frozen window during "I pressed Install" is indistinguishable from a crash -- so it runs on a worker while a cancellable progress dialog pumps the event loop. Cancelling returns a :class:`spacr.updater.DryRun` that did not answer, which :func:`spacr.updater.install_decision` refuses, because an install whose consequences are unknown is not offered. """ import threading from PySide6.QtWidgets import QProgressDialog from ...updater import DryRun, dry_run_install def _run(requirement): """Dry-run an install and hand the outcome back.""" outcome: Dict[str, Any] = {} def _work(): """Do the dry run. Called off the GUI thread.""" outcome['value'] = dry_run_install(requirement) worker = threading.Thread(target=_work, daemon=True) dialog = QProgressDialog( f"Working out what installing {requirement} would change...", "Cancel", 0, 0, parent) dialog.setWindowTitle("Checking") dialog.setMinimumDuration(0) dialog.setAutoClose(False) dialog.setAutoReset(False) worker.start() dialog.show() while worker.is_alive(): QApplication.processEvents() worker.join(0.05) if dialog.wasCanceled(): break dialog.close() if 'value' not in outcome: return DryRun(str(requirement), False, error="Cancelled before the resolver answered. " "Nothing has been installed.") return outcome['value'] return _run
[docs] def explain(anchor, entries, index: int = 0, *, pinned: bool = False, parent=None, anchor_rect=None, on_installed=None): """Open the shared panel on ``entries[index]`` with Install already wired. The two-line form for a caller that has one control and one entry -- the Image UMAP's GPU acceleration is one:: from spacr.gpu_reduce import availability_entry explain(self._gpu_button, [availability_entry()], parent=self) The panel is a process-wide singleton, so the Install connection is replaced on every call rather than accumulated; two callers connected at once would both answer one press. :param anchor: the widget the panel docks under. :param entries: mappings from ``availability_entry`` -- see :meth:`AvailabilityPanel.show_for`. :param pinned: open it by the keyboard route (activated and focused). :param parent: what the install dialogs are parented to; defaults to ``anchor``. :param on_installed: called with the offer after a successful install, for a caller that has to re-probe. :returns: the panel, or ``None`` when ``entries`` is empty. """ if not entries: return None panel = AvailabilityPanel.instance() def _install(offer): """Run one install offer and report what happened.""" outcome = run_install_offer(parent if parent is not None else anchor, offer) if outcome == "installed" and on_installed is not None: on_installed(offer) panel.set_install_handler(_install) if pinned: panel.open_for(anchor, entries, index, anchor_rect=anchor_rect) else: panel.show_for(anchor, entries, index, anchor_rect=anchor_rect, immediate=True) return panel