spacr.qt.widgets.availability_panel

Explain unavailable options and offer an applicable next step.

A control whose entry is unavailable has three jobs it cannot do while it is honestly dead: say WHY, link the documentation, and – where it is possible in this environment – offer to make itself available. This module is where all three live, once, because the regression backend picker and the Image UMAP’s GPU acceleration ask exactly the same question and a second interactive tooltip is a second set of hover, focus and dismissal bugs.

Why this is not a QToolTip: PySide6 reports:

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

QToolTip is a STATIC PAINTER. It renders rich text and nothing inside it can be clicked, hovered or focused, so an “install link in the tooltip” drawn with it is a link that looks pressable and is not. 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 DEAD. 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. disable_combo_row() does both and is what the callers use.

THE THREE THINGS AN INTERACTIVE TOOLTIP HAS TO GET RIGHT, and how each is answered here:

  • IT MUST NOT VANISH 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 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. 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, because a reader who is not holding the mouse must not have the panel taken away from them.

WHAT PRESSING INSTALL DOES is run_install_offer(), and it has THREE answers rather than two – see 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.

Classes

AvailabilityPanel

The hover panel a greyed-out option opens: reason, API, Install.

Functions

disable_combo_row(→ None)

Disable a combo row and remove it from keyboard selection.

explain(anchor, entries[, index, pinned, parent, ...])

Open the shared panel on entries[index] with Install already wired.

install_word_for(→ str)

The link word for an offer's action; '' when there is none.

run_install_offer(→ str)

Handle the three outcomes of an install offer in one place.

Module Contents

class spacr.qt.widgets.availability_panel.AvailabilityPanel[source]

Bases: PySide6.QtWidgets.QFrame

The hover panel a greyed-out option opens: reason, API, Install.

Access through 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 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.

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.

The API word – exposed so a test can measure where it sits.

body_label() → PySide6.QtWidgets.QLabel[source]

The explanation label.

cancel_hide() → None[source]

Stop a pending hide (the pointer came back).

corridor() → PySide6.QtCore.QRect | None[source]

The rectangle the pointer is allowed to be inside while travelling.

The union of the anchor’s rectangle and the panel’s, which is exactly the region a pointer moving from one to the other passes through. A naive implementation hides on the anchor’s leave event and the pointer never arrives.

current_entry() → Dict[str, Any] | None[source]

The entry on screen, or None when nothing is shown.

current_offer()[source]

The spacr.updater.InstallOffer on screen, or None.

dismiss() → None[source]

Close the panel and hand focus back where it came from.

enterEvent(event)[source]

The pointer arrived: the panel stays.

Parameters:

event – the enter event, passed on to the base class after any pending hide is cancelled.

entries() → List[Dict[str, Any]][source]

The entries this panel is currently cycling through.

eventFilter(obj, event)[source]

A press anywhere outside the panel dismisses it.

Parameters:
  • obj – the watched object; the filter is installed on the application, so this is whatever received the event. Not read.

  • 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.

hideEvent(event)[source]

Drop the app filter whenever the panel leaves the screen.

Parameters:

event – the hide event, passed on to the base class.

The Install word – exposed for the same reason.

classmethod instance() → AvailabilityPanel[source]

The process-wide panel, created on first use.

is_pinned() → bool[source]

Was this opened by keyboard? A pinned panel ignores hover timers.

keyPressEvent(event)[source]

Escape closes; Up/Down move between the entries.

Parameters:

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.

leaveEvent(event)[source]

The pointer left: start the interruptible hide.

Parameters:

event – the leave event, passed on to the base class after a 100 ms hide is scheduled.

open_for(anchor: PySide6.QtWidgets.QWidget, entries, index: int = 0, *, anchor_rect: PySide6.QtCore.QRect | None = None) → None[source]

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.

Parameters:
  • anchor – the widget the panel is docked under; see show_for().

  • entries – availability mappings ({title, reason, url, offer}) as show_for() takes them; an empty list shows nothing.

set_install_handler(slot) → None[source]

Make slot the ONLY receiver of 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.

Parameters:

slot – callable taking the current entry’s offer, or None to leave the signal with no receiver.

show_entry(index: int) → None[source]

Move to another of the entries without moving the panel.

Parameters:

index – entry position; wraps around modulo the number of entries, so -1 is the last one.

show_for(anchor: PySide6.QtWidgets.QWidget, entries, index: int = 0, *, anchor_rect: PySide6.QtCore.QRect | None = None, pinned: bool = False, immediate: bool = False) → None[source]

Show the panel for entries[index], docked under anchor.

Parameters:
  • anchor – the widget the panel belongs to. Used for placement and as one half of the corridor the pointer may cross.

  • entries – mappings as spacr.regression_backends.availability_entry() returns – {title, reason, url, offer}. More than one makes the panel cyclable with Up/Down when it is pinned.

  • index – which of them to show.

  • 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.

  • pinned – opened by keyboard. See open_for().

  • immediate – explicit click/keyboard request, bypassing hover delay.

start_hide(delay_ms: int | None = None) → None[source]

Schedule the hide the pointer is allowed to interrupt.

spacr.qt.widgets.availability_panel.disable_combo_row(combo, index: int, *, tooltip: str = '') → None[source]

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.

Parameters:
  • combo – QComboBox backed by a QStandardItemModel.

  • index – Row to disable.

  • tooltip – Explanatory tooltip; an empty string leaves it unchanged.

spacr.qt.widgets.availability_panel.explain(anchor, entries, index: int = 0, *, pinned: bool = False, parent=None, anchor_rect=None, on_installed=None)[source]

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 exactly that:

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.

Parameters:
  • anchor – the widget the panel docks under.

  • entries – mappings from availability_entry – see AvailabilityPanel.show_for().

  • pinned – open it by the keyboard route (activated and focused).

  • parent – what the install dialogs are parented to; defaults to anchor.

  • 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.

spacr.qt.widgets.availability_panel.install_word_for(action) → str[source]

The link word for an offer’s action; '' when there is none.

Parameters:

action – the offer’s action, "install", "elsewhere", "impossible" or "ready" (which gives ''); any other value gives "What it needs".

spacr.qt.widgets.availability_panel.run_install_offer(parent, offer, *, confirm=None, inform=None, dry_run=None, install=None) → str[source]

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 – said plainly, 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, and a flow that could only be tested through one could not be tested at all.

Parameters:
  • parent – the widget dialogs are parented to.

  • offer – a spacr.updater.InstallOffer.

  • confirm – (title, text) -> bool. Defaults to a QMessageBox.question whose default button is No.

  • inform – (title, text) -> None. Defaults to QMessageBox.information.

  • dry_run – (requirement) -> DryRun. Defaults to spacr.updater.dry_run_install() behind a progress dialog – the resolver talks to the network and has been measured taking minutes, and a frozen window is indistinguishable from a crash.

  • install – (command) -> (code, output). Defaults to spacr.updater.run_install_command().

Returns:

one of 'ready', 'explained', 'declined', 'refused', 'installed', 'failed'.

Nested helpers

_default_confirm._confirm(title: str, text: str) → bool

Ask a yes/no question in a modal box.

spacr/qt/widgets/availability_panel.py:781

_default_dry_run._run(requirement)

Dry-run an install and hand the outcome back.

spacr/qt/widgets/availability_panel.py:819

_default_dry_run._run._work()

Do the dry run. Called off the GUI thread.

spacr/qt/widgets/availability_panel.py:823

_default_inform._inform(title: str, text: str) → None

Say something in a modal box.

spacr/qt/widgets/availability_panel.py:796

explain._install(offer)

Run one install offer and report what happened.

spacr/qt/widgets/availability_panel.py:880