"""HomePage — the Home screen.
┌────────────────────────────────────────────────────────────────┐
│ 🖼 spaCR End-to-end microscopy → single-cell measurements … │
│ ┌ Mask · running ────── 41 of 96 ──── [Open] [Pause] ────────┐ │
│ │ Home │ Core │ Data │ Segmentation models │ Results │ Toxo │ │ QUEUED
│ │ CORE 9 ────────────────────────────────────────────────── │ │ RECENT
│ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ │ SYSTEM
│ │ │ ▧ │ │ ▧ │ │ ▧ │ │ ▧ │ │ ▧ │ │ │ NEWS
│ │ │Mask│ │Time│ │Moti│ │Meas│ │Anno│ │ │ TOTALS
│ │ └────┘ └────┘ └────┘ └────┘ └────┘ │ │ ────────
│ │ DATA 6 ───────────────────────────────────────────────────│ │ ● Alpha
│ │ ┌────┐ ┌────┐ … │ │ ● Beta
│ └────────────────────────────────────────────────────────────┘ │ ● Stable
│ Hover a tile to see what it does. │
└────────────────────────────────────────────────────────────────┘
The first tab displays every registered app grouped into the same sections as
the category-filter tabs. :class:`AppTile` supplies one consistent icon-and-
name tile in every view; tooltips and the hint bar provide descriptions.
``categories`` and ``bands`` remain separate inputs so navigation and the
all-app layout can evolve independently while both derive from
:data:`spacr.qt.app.APPS`.
Maturity is represented by each tile's ``stage`` property and the legend,
using :data:`spacr.qt.app.APP_STAGE` and
:data:`spacr.qt.theme.STAGE_HOVER`. The right column presents state rather
than navigation: queued work, recent runs, system information, release news,
totals, and maturity labels.
Home subscribes to :data:`spacr.qt.bridge.registry` to display jobs started by
any screen. :class:`RunningBanner` exposes only the controls supported by the
worker; cooperative Pause remains disabled when the pipeline has no
:class:`spacr.qt.bridge.PauseGate` checkpoints. Widget colors are resolved
from :func:`spacr.qt.theme.active_palette` so runtime theme changes remain
consistent.
"""
from __future__ import annotations
import os
import re
import warnings
from html import escape
from typing import Callable, Dict, List, Optional, Sequence, Tuple
from PySide6.QtCore import QEvent, QSize, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap
from PySide6.QtWidgets import (
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QProgressBar,
QPushButton,
QScrollArea,
QSizePolicy,
QTabWidget,
QVBoxLayout,
QWidget,
)
from ..hidpi import follow_device_ratio, scaled_for
from ..theme import (
SPACING, TILE_H, TILE_ICON_PX, TILE_MAX_W, TILE_W, font_px,
make_transparent, palette_for,
)
from .divider import Divider
from .height_grip import HeightGrip
#: Hero brand sizes. The mark and wordmark are the first thing on the first
#: screen, so they are sized to read as a masthead rather than as a row of
#: labels. Kept as named constants because the logo's pixmap scale and the
#: label's font size have to move together to stay optically balanced.
HERO_LOGO_PX = 72
HERO_TITLE_PX = 52
[docs]
def active_palette() -> dict:
"""The palette for the theme that is on screen *right now*.
Not ``theme.PALETTE``: that module-level dict is the dark palette and
nothing ever updates it, so a widget that inlines colours from it
renders dark on every theme. On the light theme that produced black
panels with black text in the right-hand column — unreadable, and
invisible to any test that only checks widget structure.
Home is rebuilt from scratch on a theme change
(``MainWindow._rebuild_startup_page``), so resolving once per widget
construction is enough.
"""
try:
from ..preferences import resolve_effective_theme
return palette_for(resolve_effective_theme())
except Exception:
return palette_for("dark")
_DEFAULT_HINT = "Hover a tile to see what it does."
#: Why the Pause control is disabled. Shown as its tooltip, and asserted
#: by the test suite so it cannot quietly become a lie.
[docs]
PAUSE_UNAVAILABLE = (
"Pause is not available for this module.\n\n"
"Pausing means holding the pipeline at a point where nothing is "
"half-written — between fields, not mid-write. spaCR's pipelines do "
"not yet offer such a checkpoint, so a Pause button here could only "
"freeze the thread wherever it happened to be, which can truncate a "
"mask file or leave a field measured into some tables and not "
"others.\n\n"
"Use Stop, or queue plates so the run can be halted between them."
)
PAUSE_AVAILABLE = "Hold this run at its next safe checkpoint."
#: Appended to the header of an aside panel whose numbers are not yet
#: trusted. Lower-case on purpose: the header is upper-cased and
#: letter-spaced, so a lower-case marker reads as an annotation ON the
#: heading rather than as another word IN it.
BETA_SUFFIX = " (beta)"
#: Why those panels carry it. Shown as the header's tooltip, so the mark
#: is an explanation rather than a shrug.
BETA_PANEL_TOOLTIP = (
"Beta: this panel is still being worked on and its numbers may be "
"incomplete or wrong. Nothing else on this page depends on it.")
def _escape_amp(text: str) -> str:
"""Double any ``&`` so Qt draws it instead of eating it.
``QTabBar`` (like ``QToolButton``) reads a lone ``&`` as a mnemonic:
"Results & QC" rendered as "Results QC" with an underlined Q. No
tab label carries an ampersand today — that section was retired —
which is exactly when this stops being applied and the next name
with one in it ships broken. It stays, and it stays tested.
"""
return text.replace("&", "&&")
def _find_logo_pixmap() -> Optional[QPixmap]:
"""The bundled spaCR logo, re-inked for the theme, or ``None``."""
from ..iconset import themed_pixmap
here = os.path.dirname(os.path.abspath(__file__))
for candidate in ("logo_spacr.png", "logo_spacr_v1.png"):
path = os.path.normpath(
os.path.join(here, "..", "..", "resources", "icons", candidate))
if os.path.isfile(path):
pix = themed_pixmap(path) or QPixmap(path)
if not pix.isNull():
return pix
return None
def _fmt_elapsed(seconds: float) -> str:
"""Render an elapsed time compactly.
:param seconds: the duration; negatives read as zero.
:returns: seconds, then minutes and seconds, then hours and minutes --
each with the smaller unit zero-padded so a column of them lines up.
"""
seconds = int(max(0, seconds))
if seconds < 60:
return f"{seconds}s"
if seconds < 3600:
return f"{seconds // 60}m {seconds % 60:02d}s"
return f"{seconds // 3600}h {(seconds % 3600) // 60:02d}m"
[docs]
class AppTile(QPushButton):
"""**The** tile: a large square-ish button, icon over module name.
One class for every tab including Home. There used to be two — a
dense icon-beside-name row for Home and a tall card carrying the
app's one-line description everywhere else — and the difference
made the first tab read as a list and the rest as a launcher. The
description is gone with them: it was a third copy of a sentence
already on the tooltip and in the hint bar at the foot of the page,
and three lines of 11 px grey under every tile is what made the
tiles small enough to need two sizes in the first place.
Deliberately **not** an :class:`HTile` subclass. ``HTile`` is a
horizontal row whose name and description live in a ``QLabel``
stack beside the button's own icon; this is a vertical stack with
the icon drawn as a child label. It has its own object name,
``AppTile``, so the stylesheet can give it a height floor without
giving one to every horizontal tile in the app.
**Its height floor is in the QSS, not here, and that is not a
style choice.** ``setFixedSize`` does not survive polish, and
neither does answering through ``sizeHint`` /
``minimumSizeHint``: the app stylesheet's blanket
``QPushButton { min-height: 22px }`` becomes a real
``setMinimumHeight(22)``, and ``qSmartMinSize`` lets an explicit
minimum override the hints. On a page that does not fit, every tile
then collapses to 22 px and paints its name over its icon. See
:data:`spacr.qt.theme.TILE_H`. The hints below are still worth
having — they are what the layout *prefers* — and
``heightForWidth`` is overridden with them because
``QWidgetItem::sizeHint`` reads it in preference to
``sizeHint().height()`` whenever it is available.
:param stage: ``stable`` / ``beta`` / ``alpha``. Set as a Qt
property, which is what the stylesheet's
``QPushButton#AppTile[stage="alpha"]:hover`` rule selects on. Set
*before* the widget is first polished, or the rule does not
apply until something else forces a repolish.
"""
def __init__(self, text: str, description: str = "",
icon: Optional[QIcon] = None, *, width: int, height: int,
icon_px: int = 52, stage: str = "stable", parent=None):
"""Build one tile: an icon over a module name.
:param text: the module name drawn on the tile, and what the tile is
identified by.
:param description: accepted and NOT DRAWN. The tile stopped showing
it because it was a third copy of a sentence already on the
tooltip and in the hint bar, and three lines of grey under every
tile is what forced two tile sizes in the first place. Kept in
the signature so callers that pass it still work.
:param icon: the module's mark, drawn above the name.
:param width: tile width in pixels.
:param height: tile height in pixels.
:param icon_px: the icon's edge in pixels.
:param stage: maturity -- ``"stable"``, ``"beta"`` or ``"alpha"``.
Set as a Qt property, so the theme paints the badge and the
maturity filter can find the tile without reading its text.
:param parent: parent widget.
"""
super().__init__(parent)
P = active_palette()
self._text = text
self._stage = str(stage or "stable")
self._size = QSize(int(width), int(height))
self.setObjectName("AppTile")
self.setProperty("stage", self._stage)
self.setCursor(Qt.PointingHandCursor)
self.setAccessibleName(text)
from ..theme import STAGE_LABEL
mark = STAGE_LABEL.get(self._stage, "")
self.setAccessibleDescription(
f"{mark} — {description}" if description and mark else
(description or mark))
col = QVBoxLayout(self)
col.setContentsMargins(10, 10, 10, 10)
col.setSpacing(8)
col.addStretch(1)
if icon is not None:
glyph = QLabel()
glyph.setPixmap(icon.pixmap(icon_px, icon_px))
glyph.setFixedSize(icon_px, icon_px)
glyph.setStyleSheet("background: transparent;")
col.addWidget(glyph, 0, Qt.AlignHCenter)
from .eliding import ElidingLabel
name = ElidingLabel(text)
name.setAlignment(Qt.AlignHCenter)
name.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
name.setStyleSheet(
f"color: {P['fg']}; font-size: {font_px(14)}px; font-weight: 500;"
"background: transparent;")
col.addWidget(name)
self._name_lbl = name
col.addStretch(1)
@property
[docs]
def text_label(self) -> str:
"""The tile's app name, matching ``HTile.text_label``."""
return self._text
@property
[docs]
def stage(self) -> str:
"""``stable`` / ``beta`` / ``alpha`` — what the hover colour says."""
return self._stage
@property
[docs]
def name_label(self):
"""The label showing the module's name.
Exposed so the text-fits sweep can measure it: a tile that elides
its own name is a module the user cannot identify.
:returns: the label.
"""
return self._name_lbl
[docs]
def is_name_elided(self) -> bool:
"""Whether the module name is being cut short to fit.
:returns: True when the label is eliding.
"""
return self._name_lbl.is_elided()
[docs]
def heightForWidth(self, width: int) -> int: # noqa: N802
"""At least the tile height, more if a child somehow needs it.
:param width: proposed tile width in pixels, passed to the base
implementation.
"""
natural = super().heightForWidth(width)
return max(self._size.height(), natural)
[docs]
def sizeHint(self) -> QSize: # noqa: N802
"""The tile's preferred size, height derived from its width.
HEIGHT FOLLOWS WIDTH because the tile is icon-over-name in a fixed
proportion; asking for a free height would let the grid stretch one
tile and not its neighbours.
:returns: the preferred size.
"""
return QSize(self._size.width(),
self.heightForWidth(self._size.width()))
[docs]
def minimumSizeHint(self) -> QSize: # noqa: N802
"""The same as :meth:`sizeHint`: a tile does not shrink below its shape.
:returns: the minimum size.
"""
return self.sizeHint()
[docs]
class Panel(QWidget):
"""Captioned box for the right-hand column.
The border lives on a ``QFrame`` with its own object name and the
rule is scoped to it. An unscoped border rule cascades into every
child row and outlines each one — a mistake this codebase has
already made once on the Home dashboard.
:param beta: mark the header with :data:`BETA_SUFFIX`. The suffix is
appended *after* the upper-casing, so it stays lower case and
reads as a mark on the heading rather than part of it.
:param title: the caption above the panel. Drawn OUTSIDE the panel's
frame, beside any action word, rather than inside it.
:param parent: parent widget; ownership only.
"""
#: Which palette colour an action word turns when the pointer is on it.
#: `Clear` and `Reset` throw something away and are red; `Refresh` only
#: re-reads and is blue. Specified as a clear button -- the word "clear" alone, turning red on hover or click)" and "a refresh
#: button (like clear button but blue)".
ACTION_INKS = {"danger": "error", "safe": "accent"}
def __init__(self, title: str, parent=None, *, beta: bool = False):
"""Build a captioned box for the right-hand column.
:param title: the caption.
:param parent: parent widget.
"""
super().__init__(parent)
P = active_palette()
self.is_beta = bool(beta)
col = QVBoxLayout(self)
col.setContentsMargins(0, 0, 0, 0)
col.setSpacing(SPACING["xs"])
head = QWidget()
head_row = QHBoxLayout(head)
head_row.setContentsMargins(0, 0, 0, 0)
head_row.setSpacing(SPACING["sm"])
self.header = QLabel(title.upper()
+ (BETA_SUFFIX if self.is_beta else ""))
self.header.setObjectName("HomePanelHeader")
self.header.setStyleSheet(
"font-family: 'Open Sans', sans-serif; font-weight: 600;"
f"font-size: {font_px(10)}px; letter-spacing: 2px;"
"background: transparent;"
f"color: {P['fg_muted']};")
if self.is_beta:
self.header.setToolTip(BETA_PANEL_TOOLTIP)
head_row.addWidget(self.header)
head_row.addStretch(1)
self._head_row = head_row
self._actions: Dict[str, QPushButton] = {}
col.addWidget(head)
self._head = head
box = QFrame()
box.setObjectName("HomePanelBox")
from ..theme import pane_surface
box.setStyleSheet(
"QFrame#HomePanelBox {"
f"background: {pane_surface('surface_alt')};"
f"border: 1px solid {P['border_soft']};"
"border-radius: 8px; }")
self.body_layout = QVBoxLayout(box)
self.body_layout.setContentsMargins(SPACING["md"], SPACING["sm"],
SPACING["md"], SPACING["sm"])
self.body_layout.setSpacing(SPACING["xs"])
col.addWidget(box)
self._box = box
make_transparent(self)
make_transparent(self._head)
[docs]
def add(self, widget: QWidget) -> QWidget:
"""Add a widget to the panel body, transparent so the box shows through.
The border and fill live on the frame around the body, so a child
painting its own background would draw a square inside the rounded
box rather than sitting in it.
:param widget: the widget to add.
:returns: the same widget, for chaining.
"""
widget.setStyleSheet("background: transparent;")
self.body_layout.addWidget(widget)
return widget
[docs]
def add_action(self, text: str, *, kind: str = "danger",
tip: str = "") -> QPushButton:
"""Put an action WORD on the right of the panel's caption.
Not a button in the styled sense -- no frame, no fill, no padding
that would make it look pressable. It is the word alone, in the
muted caption ink, and it takes its colour when the pointer is on
it or while it is held down: "just the text clear which turns red
upon hover or click" (2026-09-03).
Rendered through a QPushButton rather than a QLabel because the
word is a CONTROL: a button is what Tab reaches, what Space
activates, what a screen reader announces as pressable, and what
already has a `:pressed` state to hang the click colour on. A
clickable QLabel has none of that.
:param text: the word to draw. Not upper-cased -- the caption
beside it is, and matching it would make the word read as a
second heading.
:param kind: ``danger`` for a word that throws something away, drawn
red; ``safe`` for one that only re-reads, drawn in the accent.
:param tip: optional hover help. Also becomes the accessible
description, because that is what a screen reader reads.
:returns: the button, so the caller can connect it.
"""
P = active_palette()
ink = P[self.ACTION_INKS.get(kind, "error")]
word = QPushButton(text)
word.setObjectName("HomePanelAction")
word.setCursor(Qt.PointingHandCursor)
word.setFlat(True)
word.setStyleSheet(
"QPushButton#HomePanelAction {"
" background: transparent; border: none; padding: 0px;"
" font-family: 'Open Sans', sans-serif; font-weight: 600;"
f" font-size: {font_px(10)}px; letter-spacing: 1px;"
f" color: {P['fg_dim']}; }}"
f"QPushButton#HomePanelAction:hover {{ color: {ink}; }}"
f"QPushButton#HomePanelAction:pressed {{ color: {ink}; }}"
f"QPushButton#HomePanelAction:disabled {{"
f" color: {P['fg_dim']}; }}")
if tip:
word.setToolTip(tip)
word.setAccessibleDescription(tip)
self._head_row.addWidget(word)
self._actions[text.lower()] = word
return word
[docs]
def action(self, text: str) -> Optional[QPushButton]:
"""The action word named ``text``, or ``None``. For tests.
:param text: the action's label, matched case-insensitively.
"""
return self._actions.get(text.lower())
def _clear_body(self) -> None:
"""Take every row out of the panel's box and delete it.
The same six lines were written out in five panels; they are here
because a panel that forgets the `deleteLater` leaks a widget on
every Home revisit, and Home is revisited constantly.
Each row is hidden as it leaves, not only queued for deletion: a
queued row stays a child of the box, painted where it last stood,
until the event loop next empties its deletion queue. Above 100%
text size the new rows sit at new heights, so the old ones showed
through and the panel read as drawn twice.
"""
while self.body_layout.count():
item = self.body_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.hide()
widget.deleteLater()
def _row(label: str, value: str, value_colour: Optional[str] = None,
mono: bool = False) -> QWidget:
"""One ``label value`` line inside a :class:`Panel`."""
P = active_palette()
row = QWidget()
lay = QHBoxLayout(row)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(SPACING["sm"])
left = QLabel(label)
left.setStyleSheet(f"color: {P['fg_muted']}; font-size: {font_px(11)}px;"
"font-weight: 500; background: transparent;")
left.setMinimumWidth(48)
right = QLabel(value)
right.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
right.setStyleSheet(
f"color: {value_colour or P['fg']}; font-size: {font_px(12)}px;"
"font-weight: 500; background: transparent;"
+ ("font-family: 'JetBrains Mono', monospace;" if mono else ""))
lay.addWidget(left)
lay.addStretch(1)
lay.addWidget(right)
return row
[docs]
class RunningBanner(QFrame):
""""spaCR is doing something right now" — with honest controls.
Reads :func:`spacr.qt.bridge.registry`, so it reflects a job started
from *any* screen. Hidden entirely when nothing is running, which is
the common case and should cost the page nothing.
**On the Pause button.** It is enabled if and only if the running
job's entry point declares itself ``bridge.pausable`` — i.e. it
actually polls ``bridge.checkpoint()``. No shipped pipeline does, so
today it renders disabled with :data:`PAUSE_UNAVAILABLE` as its
tooltip. That is the whole point: a Pause button that stops the
thread wherever it happens to be is not a pause, and the honest
thing to draw is a control that says so.
:param icon_provider: called with a module key for that module's icon.
A callable rather than a dict of icons, so the banner does not build
artwork for modules that never run.
:param names: module key to the name a human reads, for the banner's
text and for :attr:`open_requested`'s meaning.
:param parent: parent widget.
"""
open_requested = Signal(str)
def __init__(self, icon_provider: Callable[[str], Optional[QIcon]],
names: Dict[str, str], parent=None):
"""Build the banner for one running module.
:param icon_provider: how to get a module's icon.
:param names: display names, keyed by module.
:param parent: parent widget.
"""
super().__init__(parent)
P = active_palette()
self.setObjectName("HomeRunningBanner")
self.setStyleSheet(
"QFrame#HomeRunningBanner {"
f"background: {P['accent_soft']};"
f"border: 1px solid {P['accent_lo']};"
"border-radius: 8px; }")
self._icon_provider = icon_provider
self._names = names
self._handle = None
row = QHBoxLayout(self)
row.setContentsMargins(SPACING["md"], SPACING["sm"],
SPACING["md"], SPACING["sm"])
row.setSpacing(SPACING["md"])
self._icon = QLabel()
self._icon.setFixedSize(32, 32)
self._icon.setStyleSheet("background: transparent;")
row.addWidget(self._icon)
text_col = QVBoxLayout()
text_col.setContentsMargins(0, 0, 0, 0)
text_col.setSpacing(1)
self._title = QLabel("")
self._title.setStyleSheet(
f"color: {P['fg']}; font-size: {font_px(14)}px; font-weight: 600;"
"background: transparent;")
self._sub = QLabel("")
self._sub.setObjectName("HomeRunningSub")
self._sub.setStyleSheet(
f"color: {P['fg_muted']}; font-size: {font_px(11)}px;"
"background: transparent;")
text_col.addWidget(self._title)
text_col.addWidget(self._sub)
row.addLayout(text_col, 1)
self._bar = QProgressBar()
self._bar.setFixedWidth(180)
self._bar.setTextVisible(False)
row.addWidget(self._bar)
self._btn_open = QPushButton("Open")
self._btn_open.setObjectName("GhostButton")
self._btn_open.setCursor(Qt.PointingHandCursor)
self._btn_open.setToolTip("Go to the screen this run belongs to.")
self._btn_open.clicked.connect(self._on_open)
row.addWidget(self._btn_open)
self._btn_pause = QPushButton("Pause")
self._btn_pause.setObjectName("GhostButton")
self._btn_pause.setCursor(Qt.PointingHandCursor)
self._btn_pause.setStyleSheet(
"QPushButton#GhostButton:disabled {"
f"color: {P['fg_dim']};"
f"border-color: {P['border_soft']};"
"background: transparent; }")
self._btn_pause.clicked.connect(self._on_pause)
row.addWidget(self._btn_pause)
self._btn_quit = QPushButton("Quit")
self._btn_quit.setCursor(Qt.PointingHandCursor)
self._btn_quit.setToolTip(
"Stop this run. You are asked whether to let it finish the "
"step it is on, or to stop it immediately.")
from ..shutdown import style_as_danger
style_as_danger(self._btn_quit, P)
self._btn_quit.clicked.connect(self._on_quit)
row.addWidget(self._btn_quit)
self.hide()
def _on_quit(self) -> None:
"""Stop the run this banner is showing.
Quits the RUN, not the application: this button is attached to one
job, and a user who wants the app gone has the one in Preferences.
Force here means `QThread.terminate()`, which `cancel_all` refuses
to do on its own and documents why -- mid-write is exactly when it
is unsafe. The difference is that here somebody has been told that
and asked for it anyway.
"""
from ..shutdown import (CANCEL, FORCE, GracefulQuitWatcher,
ask_how_to_quit, describe_active)
handle = self._handle
if handle is None:
return
name = self._names.get(handle.app_key, handle.app_key)
choice = ask_how_to_quit(self, what=name,
detail=describe_active([handle]))
if choice == CANCEL:
return
if choice == FORCE:
self._terminate(handle)
return
handle.request_cancel("quit from the Home screen")
self._quit_watcher = GracefulQuitWatcher(
self,
lambda h=handle: bool(h.is_running()),
what=name,
describe=lambda h=handle: describe_active([h]),
on_force=lambda h=handle: self._terminate(h),
)
self._quit_watcher.start()
@staticmethod
def _terminate(handle) -> None:
"""Stop a job's thread outright.
Never reached without the user having been shown what it costs.
`request_cancel` first regardless, so a worker that IS still
checking gets the chance to stop on its own terms in the moment
before the thread is taken away from it.
"""
import logging
logging.getLogger(__name__).warning(
"Force-stopping %s at the user's request", handle.app_key)
try:
handle.request_cancel("force quit from the Home screen")
except Exception:
pass
thread = getattr(handle, "thread", None)
if thread is None:
return
try:
from ..bridge import drain_thread
drain_thread(thread, getattr(handle, "worker", None),
timeout_ms=2000)
except RuntimeError:
pass
[docs]
def bind(self, handle) -> None:
"""Show ``handle``'s job, or hide the banner when it is ``None``.
:param handle: the running job's handle, whose ``app_key`` sets the
icon and title; ``None`` hides the banner.
"""
self._handle = handle
if handle is None:
self.hide()
return
key = handle.app_key
icon = self._icon_provider(key) if self._icon_provider else None
if icon is not None:
self._icon.setPixmap(icon.pixmap(32, 32))
self._title.setText(f"{self._names.get(key, key)} · running")
self._sync_pause_control()
self.refresh()
self.show()
def _sync_pause_control(self) -> None:
"""Make the pause button say what the run is actually doing.
READ FROM THE RUN, not from the last press: a run can pause itself at
a checkpoint, and a button showing what the user last clicked would
then be wrong.
"""
handle = self._handle
pausable = bool(handle is not None and handle.supports_pause)
self._btn_pause.setEnabled(pausable)
self._btn_pause.setToolTip(
PAUSE_AVAILABLE if pausable else PAUSE_UNAVAILABLE)
self._btn_pause.setAccessibleDescription(
PAUSE_AVAILABLE if pausable else PAUSE_UNAVAILABLE)
if handle is not None and handle.gate.is_paused():
self._btn_pause.setText("Resume")
else:
self._btn_pause.setText("Pause")
[docs]
def refresh(self) -> None:
"""Re-read elapsed time + progress from the handle."""
handle = self._handle
if handle is None:
return
fraction = handle.fraction()
if fraction is None:
self._bar.setRange(0, 0)
else:
self._bar.setRange(0, 100)
self._bar.setValue(int(round(fraction * 100)))
bits = [_fmt_elapsed(handle.elapsed())]
if handle.progress:
bits.append(f"{handle.progress[0]} of {handle.progress[1]}")
if handle.gate.is_paused():
bits.append("paused")
tail = handle.last_line
if tail and not tail.lstrip().startswith("Progress:"):
bits.append(tail[:70])
self._sub.setText(" · ".join(bits))
self._sync_pause_control()
def _on_open(self) -> None:
"""Go to the module this banner is about."""
if self._handle is not None:
self.open_requested.emit(self._handle.app_key)
def _on_pause(self) -> None:
"""Only reachable when the job declared itself pausable."""
handle = self._handle
if handle is None or not handle.supports_pause:
return
gate = handle.gate
gate.resume() if gate.is_paused() else gate.pause()
self._sync_pause_control()
@property
[docs]
class QueuedPanel(Panel):
"""The plate queue, when there is one.
Reads ``~/.spacr/queue.json`` through :class:`spacr.qt.plate_queue.
PlateQueue` — read-only; the Queue screen owns writes. The panel
hides itself when the queue is empty rather than drawing an empty
box, which is the difference between "nothing queued" and "queue
broken".
:param parent: parent widget.
"""
#: Rows drawn before the rest collapse into a "+N more" line.
MAX_ROWS = 4
#: Emitted after **Clear** has emptied the queue, so anything else
#: showing it -- the Queue screen, if it is built -- can re-read.
queue_cleared = Signal()
def __init__(self, parent=None):
"""Build the panel and its caption.
:param parent: parent widget.
"""
super().__init__("Queued", parent)
self._clear = self.add_action(
"Clear", kind="danger",
tip="Empty the plate queue and hide this panel.")
self._clear.clicked.connect(self.clear_queue)
self.refresh()
[docs]
def queue_items(self) -> List:
"""Whatever is in the saved plate queue right now.
Read from disk on each call rather than cached: the queue is written
by other screens, and a Home page showing a stale count is worse
than one that costs a file read when it is looked at.
:returns: the queued items, empty when there is no queue.
"""
try:
from ..plate_queue import PlateQueue
return list(PlateQueue().items())
except Exception:
return []
[docs]
def clear_queue(self) -> int:
"""Drop every item from the plate queue. Returns how many went.
WRITES, which no other part of this panel does -- the class docstring
says the Queue screen owns writes, and this is the one exception:
**Clear** empties the queue from here rather than sending the reader
to another screen to do it.
Not confirmed, and deliberately. A queue entry is a plate waiting to
be processed -- settings and a source path, no results -- so clearing
one throws away a few seconds of setting up, not any data. A
confirmation on a two-word action that costs that little is a
dialog people learn to dismiss without reading.
Every failure mode ends with the panel telling the truth about what
is in the queue, because it re-reads from disk afterwards either way.
"""
removed = 0
try:
from ..plate_queue import PlateQueue
queue = PlateQueue()
removed = len(list(queue.items()))
queue.clear()
except Exception: # noqa: BLE001
import logging
logging.getLogger(__name__).warning(
"could not clear the plate queue", exc_info=True)
self.refresh()
if removed:
self.queue_cleared.emit()
return removed
[docs]
def refresh(self) -> None:
"""Re-read the queue and redraw the panel."""
P = active_palette()
self._clear_body()
items = self.queue_items()
pending = [i for i in items
if str(getattr(i.status, "value", i.status)) in
("queued", "running")]
if not pending:
self.hide()
return
for item in pending[:self.MAX_ROWS]:
state = str(getattr(item.status, "value", item.status))
label = item.label or item.app_key
self.add(_row(label, state,
P["accent"] if state == "running"
else P["fg_muted"]))
if len(pending) > self.MAX_ROWS:
from ..i18n import tr
more = QLabel(tr("+{n} more", n=len(pending) - self.MAX_ROWS))
more.setStyleSheet(
f"color: {P['fg_dim']}; font-size: {font_px(11)}px;"
"background: transparent;")
self.add(more)
self.show()
[docs]
class RecentRunsPanel(Panel):
"""Last few journalled runs of a REAL module; each row navigates.
"Real module" is doing work here. The panel used to list whatever the
run journal's newest manifests said, and those rows were clickable
without representing runs -- they opened a ``_job`` module. On one
machine the journal held 11,046 run folders of which 7,323 were written
under the app_key ``_job`` or ``job``: a test fixture's key, written
straight into the real `~/.spacr/runs` because nothing sandboxed it
(fixed in ``tests/conftest.py``). So most rows named a module that does
not exist, and clicking one asked the window to open it.
The pollution is stopped at the source now, but a filter here is still
the right thing: this panel NAVIGATES, so a row it draws is a promise
that pressing it goes somewhere. Anything whose key is not a module Home
knows about is dropped.
:param limit: how many journalled runs to show.
:param known_keys: callable returning the module keys that exist, or a
mapping of key to display name (or either, directly). ``None``
filters nothing, which is what a standalone panel with no registry
to consult has to do.
:param parent: parent widget.
"""
run_clicked = Signal(str)
#: Emitted after **Clear** moved the watermark, so Home can re-read.
cleared = Signal()
def __init__(self, limit: int = 4, known_keys=None, parent=None,
read_now: bool = True):
"""Build the panel and its caption.
:param parent: parent widget.
:param read_now: read the journal on the calling thread now. Home
passes ``False`` and fills the panel from its worker read, so
the window is not held up by the journal.
"""
super().__init__("Recent runs", parent)
self._limit = limit
self._known_keys = known_keys
self._clear = self.add_action(
"Clear", kind="danger",
tip="Hide the runs listed here. The run journal and Run "
"History keep them.")
self._clear.clicked.connect(self.clear_list)
self.refresh(None if read_now else [])
def _registry(self):
"""Whatever ``known_keys`` resolves to right now, or ``None``."""
source = self._known_keys
if source is None:
return None
try:
return source() if callable(source) else source
except Exception: # noqa: BLE001
return None
[docs]
def known(self) -> Optional[set]:
"""The module keys that exist, or ``None`` for "do not filter"."""
value = self._registry()
return None if value is None else {str(k) for k in value}
[docs]
def name_for(self, key: str) -> str:
"""``key``'s display name when Home knows one, else ``key`` itself.
A row used to be captioned with the raw app_key, which is what put
``_job`` and ``mask`` on the dashboard in the same typeface as each
other. The names are already in Home; the panel just had no way to
ask for them.
:param key: application key to name; returned unchanged when the name
registry does not know it.
"""
value = self._registry()
if isinstance(value, dict):
return str(value.get(key, key) or key)
return key
[docs]
def clear_list(self) -> None:
"""Hide every run listed, by moving the watermark to now.
NOTHING IS DELETED. See
:func:`spacr.qt.preferences.get_dashboard_watermark` for why: this
panel reads the run journal, and the journal is the record Run
History searches and a run's manifest documents.
"""
from ..preferences import set_dashboard_watermark
set_dashboard_watermark("runs")
self.refresh([])
self.cleared.emit()
[docs]
def read(self) -> list:
"""The journal entries this panel would show. **Worker-thread safe.**
Split out of :meth:`refresh` so :class:`HomePage` can call it off the
GUI thread: it touches no widget, only the run journal.
``recent_runs`` opens and JSON-parses every manifest in a bounded
window of the newest run folders before it sorts and truncates --
measured at 540 ms over 4,865 of them -- so it is not something the
GUI thread should be doing on the way back to Home.
THE FILTERING HAPPENS HERE, not in `refresh`, for two reasons. It is
the half that runs off the GUI thread, and it changes how much has to
be read: dropping four rows in five after asking for five leaves one,
so this asks for a multiple of the limit and truncates afterwards.
"""
try:
from spacr.run_journal import recent_runs
from ..preferences import get_dashboard_watermark
except Exception: # noqa: BLE001
return []
try:
entries = recent_runs(limit=max(self._limit * 8, 32))
except Exception: # noqa: BLE001
return []
keys = self.known()
since = get_dashboard_watermark("runs")
kept = []
for entry in entries:
key = str(entry.get("app_key") or "")
if keys is not None and key not in keys:
continue
if since and str(entry.get("start_utc") or "") <= since:
continue
kept.append(entry)
if len(kept) >= self._limit:
break
return kept
[docs]
def refresh(self, runs: Optional[list] = None) -> None:
"""Redraw the panel.
:param runs: entries a worker has already read. ``None`` reads them
here, on the calling thread — which is what a standalone panel
and the tests do, and what :class:`HomePage` deliberately does
not.
"""
P = active_palette()
self._clear_body()
if runs is None:
runs = self.read()
self._clear.setEnabled(bool(runs))
if not runs:
hint = QLabel("No runs yet.")
hint.setStyleSheet(
f"color: {P['fg_dim']}; font-size: {font_px(11)}px;"
"font-style: italic; background: transparent;")
hint.setWordWrap(True)
self.add(hint)
return
for entry in runs:
self.add(self._run_row(entry))
def _run_row(self, entry: dict) -> QWidget:
"""One row describing a finished run.
:param entry: the run to describe.
:returns: the row widget.
"""
P = active_palette()
ok = entry.get("status") == "success"
key = entry.get("app_key", "?")
elapsed = int(entry.get("elapsed_s") or 0)
btn = QPushButton()
btn.setCursor(Qt.PointingHandCursor)
btn.setFlat(True)
btn.setStyleSheet(
"QPushButton { background: transparent; border: none;"
" text-align: left; padding: 1px; }"
f"QPushButton:hover {{ background: {P['surface_hi']}; }}")
lay = QHBoxLayout(btn)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(SPACING["sm"])
dot = QLabel("●" if ok else "○")
dot.setFixedWidth(12)
dot.setStyleSheet(
f"color: {P['success'] if ok else P['error']};"
f"font-size: {font_px(11)}px; background: transparent;")
name = QLabel(self.name_for(key))
name.setStyleSheet(f"color: {P['fg']}; font-size: {font_px(12)}px;"
"background: transparent;")
when = QLabel(_fmt_elapsed(elapsed))
when.setStyleSheet(f"color: {P['fg_dim']}; font-size: {font_px(11)}px;"
"background: transparent;")
lay.addWidget(dot)
lay.addWidget(name, 1)
lay.addWidget(when)
btn.clicked.connect(lambda _=False, k=key: self.run_clicked.emit(k))
return btn
[docs]
class SystemPanel(Panel):
"""GPU / VRAM / Disk, read on build and on every Home revisit.
Every reading degrades to a string rather than vanishing — a blank
row reads as "broken", while ``n/a`` honestly says the lightweight
system probe could not measure a device. Home must not import a model
runtime merely to decorate the dashboard.
:param parent: parent widget.
"""
def __init__(self, parent=None):
"""Build the panel and its caption.
:param parent: parent widget.
"""
super().__init__("System", parent)
self._refresh = self.add_action(
"Refresh", kind="safe", tip="Re-read GPU, VRAM and disk now.")
self._refresh.clicked.connect(self.refresh)
self.refresh()
[docs]
def refresh(self) -> None:
"""Re-read GPU, VRAM and disk, and redraw the panel."""
self._clear_body()
self.add(_row("GPU", self.gpu_util()))
self.add(_row("VRAM", self.gpu_vram()))
self.add(_row("Disk", self.disk_used()))
@staticmethod
[docs]
def gpu_util() -> str:
"""Current GPU utilisation, as text for display.
NEVER RAISES. A missing NVML, a machine with no GPU and a driver
mismatch are all ordinary here, and none of them is a reason for the
Home page to fail to build.
:returns: the reading, or a dash when it cannot be taken.
"""
try:
nvml = _nvml()
if nvml is None:
raise RuntimeError("no NVML")
handle = nvml.nvmlDeviceGetHandleByIndex(0)
return f"{nvml.nvmlDeviceGetUtilizationRates(handle).gpu}%"
except Exception:
return "n/a"
@staticmethod
[docs]
def gpu_vram() -> str:
"""Current VRAM use, as text for display.
Never raises, for the same reason as :meth:`gpu_util`.
:returns: the reading, or a dash when it cannot be taken.
"""
try:
nvml = _nvml()
if nvml is None:
raise RuntimeError("no NVML")
handle = nvml.nvmlDeviceGetHandleByIndex(0)
info = nvml.nvmlDeviceGetMemoryInfo(handle)
return f"{info.used / 1e9:.1f} / {info.total / 1e9:.0f} GB"
except Exception:
return "n/a"
@staticmethod
[docs]
def disk_used() -> str:
"""Disk use for the working volume, as text for display.
:returns: the reading, or a dash when it cannot be taken.
"""
try:
import shutil
usage = shutil.disk_usage(os.path.expanduser("~"))
return f"{int(100 * usage.used / usage.total)}%"
except Exception:
return "n/a"
#: Sentinel so a machine with no GPU is probed once, not once per refresh.
_UNSET = object()
_NVML = _UNSET
def _nvml():
"""Return an initialised NVML module, or None when there is no NVIDIA GPU.
``nvidia-ml-py`` is the maintained package and ``pynvml`` is the retired
one, and BOTH install a module called ``pynvml`` -- so the import line is
the same either way and only the installed distribution differs. The
retired one warns on import, which reached the console at every start.
The suppression is local rather than a global filter because a global
one was already in place and did not hold: the warning is raised while
the module body executes, and anything that has reset the warning
filters by then -- a Qt plugin, a library imported earlier -- lets it
through. ``catch_warnings`` around the import itself cannot be reset by
somebody else.
:returns: the NVML module with ``nvmlInit`` already called, or None.
"""
global _NVML
if _NVML is _UNSET:
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore", FutureWarning)
warnings.simplefilter("ignore", DeprecationWarning)
import pynvml
pynvml.nvmlInit()
_NVML = pynvml
except Exception: # noqa: BLE001
_NVML = None
return _NVML
[docs]
class TotalsPanel(Panel):
"""Aggregate counts from the automatically complete run journal.
:param parent: parent widget.
"""
#: Emitted after **Reset** moved the watermark.
reset_requested = Signal()
def __init__(self, parent=None, read_now: bool = True):
"""Build the panel and its caption.
:param parent: parent widget.
:param read_now: read the journal on the calling thread now. Home
passes ``False`` and fills the panel from its worker read.
"""
super().__init__("Totals", parent)
self._reset = self.add_action(
"Reset", kind="danger",
tip="Start these counts from now. The run journal and Run "
"History keep every run.")
self._reset.clicked.connect(self.reset_counts)
self.refresh(None if read_now else {})
[docs]
def reset_counts(self) -> None:
"""Count from now on, by moving the watermark.
NOTHING IS DELETED, for the same reason **Clear** on Recent runs
deletes nothing — see
:func:`spacr.qt.preferences.get_dashboard_watermark`. What these
counts are FOR is telling the user how much this installation has
done, and a reset that removed the manifests would take Run History
and every run's provenance with it.
"""
from ..preferences import set_dashboard_watermark
set_dashboard_watermark("totals")
self.refresh()
self.reset_requested.emit()
[docs]
def read(self) -> dict:
"""The journal totals. **Worker-thread safe** — see
:meth:`RecentRunsPanel.read`; ``journal_totals`` walks the same
thousands of manifests, measured at 247 ms.
Counted from the **Reset** watermark when one is set, which is the
one case this cannot answer out of the cached totals file: that file
holds LIFETIME counts, so a windowed count has to walk the runs the
window covers. It is bounded by the window, which is the property
that makes the feature affordable — somebody who reset yesterday
is counting yesterday's runs, not eleven thousand of them.
"""
try:
from ..preferences import get_dashboard_watermark
since = get_dashboard_watermark("totals")
except Exception: # noqa: BLE001
since = ""
try:
if since:
return self._totals_since(since)
from spacr.run_journal import journal_totals
return journal_totals()
except Exception:
return {"total_runs": 0, "mask_runs": 0, "measure_runs": 0,
"models_recorded": 0}
@staticmethod
def _totals_since(since: str) -> dict:
"""Counts over the runs that started after ``since``.
ISO-8601 UTC strings compare lexicographically in time order. Run
folders are named by their UTC start second, so only folders named
from an hour before ``since`` onwards are opened: the cost follows the
window, not the size of the journal.
"""
import json
from datetime import datetime, timedelta, timezone
from spacr.run_journal import _run_dir_names, runs_root
counts = {"total_runs": 0, "mask_runs": 0, "measure_runs": 0,
"classify_runs": 0, "models_recorded": 0}
root = runs_root()
names = sorted(_run_dir_names(root)) if root.exists() else []
try:
moment = datetime.fromisoformat(since.replace("Z", "+00:00"))
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
floor = (moment.astimezone(timezone.utc) - timedelta(hours=1)
).strftime("%Y-%m-%d_%H%M%S")
names = [n for n in names if n[:17] >= floor]
except ValueError:
pass
for name in names:
try:
entry = json.loads((root / name / "manifest.json").read_text())
except (OSError, ValueError):
continue
if str(entry.get("start_utc") or "") <= since:
continue
counts["total_runs"] += 1
bucket = f"{str(entry.get('app_key') or '')}_runs"
if bucket in counts:
counts[bucket] += 1
return counts
[docs]
def refresh(self, totals: Optional[dict] = None) -> None:
"""Redraw the panel.
:param totals: counts a worker has already read; ``None`` reads them
on the calling thread.
"""
self._clear_body()
if totals is None:
totals = self.read()
self._reset.setEnabled(bool(totals.get("total_runs", 0)))
self.add(_row("Runs", str(totals.get("total_runs", 0))))
self.add(_row("Mask", str(totals.get("mask_runs", 0))))
self.add(_row("Meas.", str(totals.get("measure_runs", 0))))
self.add(_row("Models", str(totals.get("models_recorded", 0))))
[docs]
class StageLegend(Panel):
"""What the three hover colours mean. One row per stage.
The legend follows the numeric status panels in the right-hand column
because it explains the module tiles rather than reporting machine state.
Each row draws the stage's hue as a filled swatch *and* names the
stage in words. Colour alone would fail WCAG 1.4.1 and would be
invisible to the colour-blind mode this app already ships — the
words are what make it a legend rather than a palette.
The rows are built from :data:`spacr.qt.theme.STAGE_HOVER`, which is
the same table the stylesheet builds the hover rules from, so the
swatch and the tile it explains cannot drift apart.
:param parent: parent widget.
"""
#: Side of the colour chip in px, at 100 % font scale.
SWATCH = 12
def __init__(self, parent=None):
"""Build the panel and its caption.
:param parent: parent widget.
"""
super().__init__("Module state", parent)
from ..theme import STAGE_LABEL, STAGE_NOTE
self.header.setToolTip(
"Hover any module tile and it lights up in the colour of "
"how finished it is.")
self._rows: Dict[str, QWidget] = {}
for stage in ("alpha", "beta", "stable"):
row = self._legend_row(stage, self.swatch_colour(stage),
STAGE_LABEL[stage], STAGE_NOTE[stage])
self._rows[stage] = self.add(row)
def _legend_row(self, stage: str, colour: str, label: str,
note: str) -> QWidget:
"""One row explaining what a maturity colour means.
:param stage: the stage's name.
:returns: the row widget.
"""
from ..preferences import scaled_px
P = active_palette()
row = QWidget()
row.setToolTip(note)
lay = QHBoxLayout(row)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(SPACING["sm"])
side = scaled_px(self.SWATCH)
chip = QLabel()
name_id = f"StageSwatch_{stage}"
chip.setObjectName(name_id)
chip.setFixedSize(side, side)
chip.setStyleSheet(
f"QLabel#{name_id} {{ background: {colour};"
f" border: 1px solid {P['border']}; border-radius: 3px; }}")
name = QLabel(label)
name.setStyleSheet(f"color: {P['fg']}; font-size: {font_px(12)}px;"
"font-weight: 500; background: transparent;")
lay.addWidget(chip)
lay.addWidget(name, 1)
return row
@staticmethod
[docs]
def swatch_colour(stage: str) -> str:
"""The hex this legend draws for ``stage``.
The same function the stylesheet builds the hover rules from, so
the swatch and the tile it explains cannot come apart.
:param stage: maturity stage name; an unknown stage gets the
``'stable'`` colour.
"""
from ..theme import stage_hover
return stage_hover(stage)
[docs]
def row_for(self, stage: str) -> Optional[QWidget]:
"""The legend row explaining one maturity stage.
:param stage: the stage's name.
:returns: the row widget, or None when that stage has no row.
"""
return self._rows.get(stage)
[docs]
class NewsPanel(Panel):
"""Every spaCR release, with links, in a box the reader can resize.
THE NOTES ARE BUNDLED, and the bundle is what draws. They come from
``spacr/resources/release_notes.json``, which
``tools/build_release_notes.py`` writes from the GitHub releases and
which ``.github/workflows/release.yml`` refreshes on every release.
This panel is on the first screen the application shows, so making its
CONTENT depend on api.github.com would mean a dashboard that is empty
offline, throttled behind a shared NAT, and slower to draw than the
window it is in. The bundled file therefore remains the offline source
of truth and the panel is complete before anything touches a socket.
AND THEN IT CATCHES UP. The wheel for a release cannot contain its own
release note -- the note is written when the GitHub release is
published, which is after that wheel is on PyPI -- so a bundled file is
always one release behind the build carrying it, and that is what went
wrong: "im on 1.5.1.0 and the news only goes to 1.5.0.7. the news
section should always automatically reflect the latest spacr release
news." So after the page is shown,
:attr:`refresh_requested` asks the window to read the public releases
list on a worker thread, and :meth:`apply_releases` merges whatever
comes back in front of the bundled list. Nothing here opens a socket:
the panel only asks, and a fetch that fails, is rate-limited, is
switched off in Preferences, or simply finds nothing newer leaves the
bundled list exactly as it was drawn.
There was previously no feed at all and this panel said so -- "No
release notes bundled with this build" -- which was honest and useless.
What it shows now is the real thing: "News should contain all the
releas information with links. i nkow that there was release information
for the current 1.5.0.4 version. this one as well as all of the other
ones should be scrollable and the user should be able to controll the
height."
So: every release newest-first, each one's body rendered with its links
live, the whole list inside a scroll area, and a grip along the bottom
edge that drags the box taller or shorter. The height is remembered
between sessions -- a reader who made it tall wants it tall next time.
The update check stays a BUTTON. Offering to install something is a
decision, so it is still made only when pressed.
:param version: the build to name in the heading. Empty leaves the
heading as the translated word alone -- the two are kept separate
because the catalog is keyed on "News", so composing the release into
the caption first would leave the only aside panel that names a build
in English.
:param parent: parent widget.
"""
check_requested = Signal()
#: Emitted once, after the panel has been shown, to ask the window for
#: a newer release list than the one in this wheel. It carries nothing
#: and it opens nothing: the window answers it on a worker thread and
#: hands the result back through :meth:`apply_releases`. A panel built
#: in a test, or on a window that does not connect it, simply never
#: gets an answer and keeps drawing the bundled list.
refresh_requested = Signal()
#: Height of the scrolling list in px at 100 % font scale: the default,
#: and how far the grip may drag it. The floor has to show a heading and
#: a line under it or dragging to it looks like a bug; the ceiling is
#: about the height of the window's content area, past which the panel
#: pushes everything below it off the page.
#:
#: 100 IS A BUDGET, NOT A TASTE. `test_no_variant_clips_elides_or_
#: overflows` measures the shipped layout at 1440x900 and today it fits
#: exactly; at 190 the aside needed 989 px of a 900 px canvas, so the
#: dashboard would have arrived needing a scrollbar on the smallest
#: common laptop. The reader drags it taller when they want to read a
#: release, and that height is remembered -- which is the whole point of
#: the grip, and why the default does not have to be generous.
NOTES_H = 100
NOTES_H_MIN = 90
NOTES_H_MAX = 720
def __init__(self, version: str = "", parent=None):
"""Build the panel and its caption.
:param parent: parent widget.
"""
from ..i18n import tr
heading = tr("News")
super().__init__(f"{heading} \u00b7 spaCR {version}" if version
else heading, parent)
P = active_palette()
self.content: Optional[QWidget] = None
self._releases = self.read_releases()
self._refresh_asked = False
self._notes = QScrollArea()
self._notes.setObjectName("HomeNewsScroll")
self._notes.setWidgetResizable(True)
self._notes.setFrameShape(QFrame.NoFrame)
self._notes.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._notes.setStyleSheet(
"QScrollArea#HomeNewsScroll { background: transparent;"
" border: none; }")
inner = QWidget()
make_transparent(inner)
self._notes_column = QVBoxLayout(inner)
self._notes_column.setContentsMargins(0, 0, 0, 0)
self._notes_column.setSpacing(SPACING["sm"])
self._notes.setWidget(inner)
self.body_layout.addWidget(self._notes)
self._placeholder = QLabel(
"No release notes bundled with this build.")
self._placeholder.setWordWrap(True)
self._placeholder.setStyleSheet(
f"color: {P['fg_muted']}; font-size: {font_px(11)}px;"
"font-style: italic; background: transparent;")
self._fill()
self._grip = _HeightGrip(self._notes, self.NOTES_H_MIN,
self.NOTES_H_MAX,
name="Resize the release notes")
self._grip.height_changed.connect(self._remember_height)
self.body_layout.addWidget(self._grip)
self._notes.setFixedHeight(self._stored_height())
check = QPushButton("Check for updates\u2026")
check.setObjectName("GhostButton")
check.setCursor(Qt.PointingHandCursor)
check.clicked.connect(self.check_requested)
self.add(check)
self._check = check
@staticmethod
[docs]
def read_releases() -> list:
"""The bundled release records, newest first, or ``[]``.
Degrades to an empty list on ANY failure, and the panel then draws
the placeholder it drew before there was a feed. A dashboard must
not fail to appear because a resource file is missing from a wheel.
"""
try:
import json
from importlib.resources import files
raw = files("spacr") / "resources" / "release_notes.json"
data = json.loads(raw.read_text(encoding="utf-8"))
releases = data.get("releases") or []
return [r for r in releases if isinstance(r, dict)]
except Exception: # noqa: BLE001
return []
def _fill(self) -> None:
"""Draw :attr:`_releases` into the scrolling column.
Called once while the panel is built and again whenever a fetched
list arrives, so the two paths cannot diverge. The placeholder is
kept rather than rebuilt: it is what a build with no bundled
resource shows, and :meth:`set_content` holds a reference to it.
"""
while self._notes_column.count():
item = self._notes_column.takeAt(0)
widget = item.widget()
if widget is not None and widget is not self._placeholder:
widget.setParent(None)
widget.deleteLater()
self._notes_column.addWidget(self._placeholder)
self._placeholder.setVisible(not self._releases
and self.content is None)
for entry in self._releases:
self._notes_column.addWidget(self._release_block(entry))
self._notes_column.addStretch(1)
[docs]
def showEvent(self, event): # noqa: N802
"""Ask for a refresh the first time the panel is shown.
AFTER the page exists and ON THE EVENT LOOP, not during
construction: the single-shot timer means the emit lands on a later
turn than this show, so Home's first paint is never waiting on it.
Once per panel, because a page that is shown again -- a tab
revisited, a font-scale rebuild -- is not news.
:param event: the Qt show event.
"""
super().showEvent(event)
if self._refresh_asked:
return
self._refresh_asked = True
QTimer.singleShot(0, self._ask_for_newer_releases)
def _ask_for_newer_releases(self) -> None:
"""Emit :attr:`refresh_requested`, unless the panel is already gone."""
try:
self.refresh_requested.emit()
except RuntimeError: # noqa: BLE001
pass
[docs]
def apply_releases(self, fetched) -> None:
"""Merge a fetched release list into the list on screen.
Silence is the contract. An empty list, a list of rubbish, or a
list that says nothing the bundled file did not already say leaves
the panel untouched and says nothing to the reader -- the failure
of an unasked-for background fetch is not the reader's problem.
:param fetched: release records from
:func:`spacr.updater.fetch_release_notes`, or anything at all.
"""
try:
merged = self.merge_releases(self._releases, fetched)
except Exception: # noqa: BLE001
return
if merged == self._releases:
return
self._releases = merged
self._fill()
@staticmethod
[docs]
def merge_releases(bundled, fetched) -> list:
"""The bundled and fetched lists as one, newest first.
One record per tag, and a fetched record wins: the same release can
have its notes edited on GitHub after it ships, and the live copy
is then the true one. Ordering is by publication date and then by
the version in the tag, so a release published on the same day as
the one before it still lands above it.
:param bundled: the records read from the wheel.
:param fetched: the records read from GitHub, or ``None``.
:returns: a new list; neither argument is modified.
"""
by_tag = {}
for entry in list(bundled or []) + list(fetched or []):
if not isinstance(entry, dict):
continue
tag = str(entry.get("tag") or entry.get("name") or "").strip()
if not tag:
continue
by_tag[tag] = entry
return sorted(by_tag.values(), key=NewsPanel._newest_first,
reverse=True)
@staticmethod
def _newest_first(entry: dict) -> tuple:
"""Sort key: publication date, then the version the tag names."""
digits = re.findall(r"\d+", str(entry.get("tag") or ""))[:4]
version = tuple(int(d) for d in digits)
return (str(entry.get("published") or ""),
version + (0,) * (4 - len(version)))
def _release_block(self, entry: dict) -> QWidget:
"""One release: its name, its date, and its notes with links live."""
P = active_palette()
block = QWidget()
make_transparent(block)
column = QVBoxLayout(block)
column.setContentsMargins(0, 0, 0, 0)
column.setSpacing(2)
tag = str(entry.get("tag") or "")
url = str(entry.get("url") or "")
name = str(entry.get("name") or tag or "spaCR")
when = str(entry.get("published") or "")
title = QLabel(
f'<a href="{escape(url, quote=True)}"'
f' style="color: {P["accent"]}; text-decoration: none;">'
f'{escape(name)}</a>' if url else escape(name))
title.setOpenExternalLinks(True)
title.setTextInteractionFlags(Qt.TextBrowserInteraction)
title.setWordWrap(True)
title.setStyleSheet(
f"color: {P['fg']}; font-size: {font_px(12)}px;"
"font-weight: 600; background: transparent;")
column.addWidget(title)
if when:
stamp = QLabel(when)
stamp.setStyleSheet(
f"color: {P['fg_muted']}; font-size: {font_px(10)}px;"
"background: transparent;")
column.addWidget(stamp)
body = self.render_body(str(entry.get("body") or ""), P["accent"])
if body:
notes = QLabel(body)
notes.setWordWrap(True)
notes.setOpenExternalLinks(True)
notes.setTextInteractionFlags(Qt.TextBrowserInteraction)
notes.setStyleSheet(
f"color: {P['fg_muted']}; font-size: {font_px(11)}px;"
"background: transparent;")
column.addWidget(notes)
return block
@staticmethod
[docs]
def render_body(body: str, link_colour: str) -> str:
"""Turn a release body into the small HTML subset a QLabel draws.
NOT A MARKDOWN RENDERER, and not trying to be. Release bodies are
GitHub-flavoured markdown; what they actually contain is bullet
lists, bare URLs and ``**bold**``, and a QLabel understands a
handful of tags. So: escape everything first -- a body is text from
a web page and must never reach a rich-text widget as markup --
then put back the three constructs that are worth having.
Escaping FIRST is what makes this safe. Linkifying first and
escaping after would escape the anchors too and show the reader
their own tags; escaping after building the HTML is the mistake that
turns a release note into an injection.
:param body: release-note text in GitHub-flavoured markdown; it is
HTML-escaped before bare URLs, ``**bold**``, ``##`` headings and
bullet lines are turned into tags.
:param link_colour: CSS colour for the generated links.
"""
text = escape(body or "").strip()
if not text:
return ""
text = re.sub(
r"(https?://[^\s<>\"']+?)([.,;:]?)(?=\s|$)",
lambda m: (f'<a href="{m.group(1)}" style="color: {link_colour};'
f' text-decoration: none;">{m.group(1)}</a>'
f"{m.group(2)}"),
text)
text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
lines = []
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("## "):
lines.append(f"<b>{stripped[3:]}</b>")
elif stripped.startswith(("* ", "- ")):
lines.append(f"\u2022 {stripped[2:]}")
else:
lines.append(stripped)
return "<br>".join(lines)
def _stored_height(self) -> int:
"""The reader's remembered height, clamped, in device px."""
from ..preferences import get_news_height, scaled_px
wanted = get_news_height() or self.NOTES_H
return scaled_px(max(self.NOTES_H_MIN,
min(self.NOTES_H_MAX, int(wanted))))
def _remember_height(self, px: int) -> None:
"""Store a dragged height, back in font-scale-independent units."""
from ..preferences import scaled_px, set_news_height
scale = max(1, scaled_px(100)) / 100.0
set_news_height(int(round(px / scale)))
@property
[docs]
def releases(self) -> list:
"""The release records currently drawn, newest first."""
return list(self._releases)
@property
[docs]
def notes_view(self) -> QScrollArea:
"""The scrolling list of releases. For tests."""
return self._notes
@property
[docs]
def grip(self) -> "_HeightGrip":
"""The drag handle under the list. For tests."""
return self._grip
[docs]
def set_content(self, widget: QWidget) -> None:
"""Replace the release list with real content.
The escape hatch :meth:`HomePage.set_reserved_content` exposes. It
hides the bundled notes rather than deleting them, so a caller that
drops content in has not thrown the feed away.
:param widget: the widget to insert at the top of the panel body; any
previous content widget is deleted and the bundled notes are
hidden.
"""
self._placeholder.hide()
self._notes.hide()
self._grip.hide()
if self.content is not None:
self.content.setParent(None)
self.content.deleteLater()
self.content = widget
self.body_layout.insertWidget(0, widget)
#: THE DRAG HANDLE UNDER THE NEWS LIST, and it is no longer Home's own.
#: It was written here, and the nested Regression containers need the same affordance on
#: every nested container in Regression's Measurements tab -- so it moved to
#: :mod:`spacr.qt.widgets.height_grip` and this name is kept pointing at it.
#: A second implementation would be a second set of bugs.
_HeightGrip = HeightGrip
#: Bounds of the Home right-hand column's text slider, as factors of the
#: designed size.
_ASIDE_TEXT_RANGE = (0.7, 1.6)
#: The right-hand column's pane in Home's splitter: its name, where its
#: dragged width is stored, and where its folded state is.
_ASIDE_PANE = "Widgets"
_ASIDE_PERSIST_KEY = "home::body"
_ASIDE_FOLD_KEY = "home/Widgets"
#: Dynamic properties holding a label's own sheet as it was built, and the
#: sheet last written from it, so a rebuilt sheet is taken as the new base.
_BASE_SHEET = "_spacrAsideBaseSheet"
_SCALED_SHEET = "_spacrAsideScaledSheet"
def _scale_text_under(root: QWidget, ratio: float,
exempt: Optional[QWidget] = None) -> None:
"""Size the text inside ``root`` by ``ratio`` of what it was built with.
:param root: the container; its descendants follow.
:param ratio: 1.0 for the built size.
:param exempt: a descendant whose own text keeps its built size.
"""
from ..live_zoom import _FONT_SIZE, _MIN_PX, _MIN_PT, ColumnTextScale
from ..live_zoom import scaled_font_sheet
def scaled(sheet: str) -> str:
"""``sheet`` with every font-size in it multiplied by ``ratio``."""
def one(match):
"""One ``font-size`` declaration, scaled and floored."""
size = float(match.group(1)) * ratio
if match.group(2).lower() == "px":
return f"font-size: {max(_MIN_PX, int(round(size)))}px"
return f"font-size: {max(_MIN_PT, round(size, 2))}pt"
return _FONT_SIZE.sub(one, sheet)
host = scaled_font_sheet(ColumnTextScale._inherited_sheet(root), ratio)
if str(root.styleSheet() or "") != host:
root.setStyleSheet(host)
for widget in root.findChildren(QWidget):
own = str(widget.styleSheet() or "")
if not own or (exempt is not None and (
widget is exempt or exempt.isAncestorOf(widget))):
continue
base = widget.property(_BASE_SHEET)
if base is None or own != widget.property(_SCALED_SHEET):
base = own
new = scaled(str(base))
widget.setProperty(_BASE_SHEET, str(base))
widget.setProperty(_SCALED_SHEET, new)
if new != own:
widget.setStyleSheet(new)
[docs]
class HomePage(QWidget):
"""Home. ``tile_clicked(str key)`` fires when a tile is pressed.
Drop-in for the page it replaces: same constructor, same signal,
same ``set_reserved_content`` escape hatch.
:param apps: ``(key, name, description, section)`` per app.
:param icon_provider: app key → QIcon (or ``None``).
:param section_notes: optional section → one line, drawn under that
category's heading on its own tab. A category with two apps in
it looks broken until it says why; passed in rather than
imported so this widget still knows nothing about
:mod:`spacr.qt.app`.
:param categories: optional ordered ``(title, [app key])`` — one
entry per tab after Home. Defaults to grouping ``apps`` by their
section in first-appearance order, which is what every test that
builds a HomePage out of a handful of tuples wants.
:param bands: optional ordered ``(title, [app key])`` for the Home
tab. Same default. Kept separate from ``categories`` because the
two answer different questions, even when — as today — they
return the same list. See the module docstring.
:param stages: optional app key → ``stable`` / ``beta`` / ``alpha``.
Becomes each tile's ``stage`` property, which is what the app
stylesheet turns into its hover colour, and what the legend at
the foot of the aside is drawn from. Anything missing is stable.
:param parent: parent widget; ownership only.
"""
tile_clicked = Signal(str)
#: Emitted when the person asks for a sample project of their own kind
#: (GitHub #130). The window answers it with
#: :func:`spacr.qt.widgets.sample_project.offer_a_sample_project`.
sample_project_requested = Signal()
#: Emitted when the page wants the window to run its update check.
update_check_requested = Signal()
#: Emitted once, after the News panel has been shown, to ask the window
#: for a release list newer than the one bundled in this wheel. The
#: window answers it on a worker thread; see
#: :meth:`spacr.qt.app.MainWindow._refresh_news`.
news_refresh_requested = Signal()
#: Declared on the class so a paint that arrives mid-construction —
#: a nested layout activation delivers one on some styles — finds an
#: answer instead of an ``AttributeError``. ``AppScreen`` learned the
#: same lesson; see its backdrop-state block.
_ambient = None
#: The masthead's logo label, or ``None`` on a build where the bundled
#: artwork could not be read. Declared here so anything asking for the
#: mark gets an answer rather than an ``AttributeError``.
_hero_mark = None
#: The tile, at 100 % font scale. One size for every tab, and read
#: from :mod:`spacr.qt.theme` rather than written here, because the
#: stylesheet needs the same numbers — see
#: :data:`spacr.qt.theme.TILE_H` for why the height floor has to be
#: expressible in QSS.
#:
#: ``TILE_MAX_W`` is how far a tile may stretch to reach the
#: right-hand edge of its row. Without a cap, a band with two apps in
#: it draws two tiles half a metre wide; without any stretch at all
#: every row stops short of the pane's edge and the page reads as
#: sparse, which is the complaint that started this redesign.
TILE_MIN_W = TILE_W
TILE_MAX_W = TILE_MAX_W
TILE_H = TILE_H
TILE_ICON_PX = TILE_ICON_PX
#: Right-hand column width. Fixed: it holds numbers, and a column of
#: numbers that reflows on every window resize is unreadable.
ASIDE_W = 300
def __init__(
self,
apps: List[Tuple[str, str, str, str]],
icon_provider: Callable[[str], Optional[QIcon]],
parent=None,
*,
section_notes: Optional[Dict[str, str]] = None,
categories: Optional[Sequence[Tuple[str, Sequence[str]]]] = None,
bands: Optional[Sequence[Tuple[str, Sequence[str]]]] = None,
stages: Optional[Dict[str, str]] = None,
):
"""Build Home: the hero, the module tabs and the right-hand column.
:param parent: parent widget.
"""
super().__init__(parent)
self._P = active_palette()
from ..job_runner import JobRunner
self._journal_jobs = JobRunner(self, app_key="home journal",
user_visible=False)
self._apps = list(apps)
self._icon_provider = icon_provider
self._section_notes = dict(section_notes or {})
self._stages = dict(stages or {})
self._by_key = {k: (k, n, d) for k, n, d, _s in self._apps}
self._categories = self._grouping(categories)
self._bands = self._grouping(bands)
self._names = {k: n for k, n, _d, _s in self._apps}
self._tile_hints: dict = {}
#: (holder, grid, tiles, tile_width) per grid, so a resize can
#: rewrap each one at its own column width.
self._grids: List[Tuple[QWidget, QGridLayout, list, int]] = []
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
body = QWidget()
col = QVBoxLayout(body)
col.setContentsMargins(SPACING["xl"], SPACING["lg"],
SPACING["xl"], SPACING["md"])
col.setSpacing(SPACING["md"])
col.addWidget(self._build_hero())
self._running_host = QWidget()
self._running_layout = QVBoxLayout(self._running_host)
self._running_layout.setContentsMargins(0, 0, 0, 0)
self._running_layout.setSpacing(SPACING["xs"])
self._banners: List[RunningBanner] = []
self._banner = self._new_running_banner()
col.addWidget(self._running_host)
from .collapsible_splitter import EDGE, CollapsibleSplitter
from ..preferences import scaled_px
split = CollapsibleSplitter(Qt.Horizontal,
persist_key=_ASIDE_PERSIST_KEY)
split.setChildrenCollapsible(False)
split.add_pane(self._build_tabs(), "Apps", stretch=1)
split.add_pane(self._build_aside(), _ASIDE_PANE, mode=EDGE,
fold_key=_ASIDE_FOLD_KEY, stretch=0,
extent=scaled_px(self.ASIDE_W))
split.splitterMoved.connect(lambda *_a: self._rewrap_grids())
split.pane_toggled.connect(lambda *_a: self._rewrap_grids())
self._aside_split = split
col.addWidget(split, 1)
self._read_the_journal()
outer.addWidget(body, 1)
from .module_hint_bar import ModuleHintBar
self._hint_bar = ModuleHintBar(_DEFAULT_HINT)
outer.addWidget(self._hint_bar)
from .. import bridge
self._closing = False
self._registry = bridge.registry()
self._registry.changed.connect(self._on_runs_changed)
self._ticker = QTimer(self)
self._ticker.setInterval(1000)
self._ticker.timeout.connect(self._refresh_run_banners)
self._on_runs_changed()
#: The drifting backdrop, or ``None``. Home takes the same animation
#: the module screens do, so the page the user lands on is not the
#: one page in the app that is flat.
self._ambient = None
self._install_ambient()
self._clear_page_surfaces()
self._apply_aside_text()
[docs]
def page_fill(self):
"""The flat colour Home paints itself, or ``None``.
The same rule, and the same reasoning, as
:meth:`spacr.qt.screens.app_screen.AppScreen.page_fill`: with an
animation installed the animation is the page, with an image
theme the window's wallpaper is, and otherwise it is this — a
real colour rather than the ``bg`` slab that no page-opacity
setting can reach.
Never raises.
"""
if (self._ambient is not None
or getattr(self, "_uses_window_backdrop", False)):
return None
try:
from ..preferences import resolve_effective_theme
from ..theme import IMAGE_THEMES, page_colour
theme = resolve_effective_theme()
if theme in IMAGE_THEMES:
return None
return QColor(page_colour(theme))
except Exception:
return None
[docs]
def paintEvent(self, event) -> None:
"""Paint the page under everything Home lays out.
Does not chain to ``super()`` when it fills: the base
implementation is what draws the stylesheet background, and that
background is the slab being replaced.
:param event: the paint event; passed to the base class only when no
page fill colour is set.
"""
colour = self.page_fill()
if colour is None:
super().paintEvent(event)
return
painter = QPainter(self)
try:
painter.fillRect(self.rect(), colour)
finally:
painter.end()
def _install_ambient(self) -> None:
"""Put the ambient animation behind Home. Never raises.
Home needs none of :class:`spacr.qt.screens.app_screen.AppScreen`'s
``_ambient_applied`` bookkeeping and no ``changeEvent`` handling:
this page is rebuilt from scratch on every theme change (see the
registry comment in ``__init__``), so a stale flat fill cannot
survive one, and there is no second attempt to guard against.
Ordered exactly as the module screens are: the preference is read
*before* anything is constructed, because not building it is the
cost the toggle exists to avoid, and the surfaces are cleared here
only *after* a successful install so that the animation is already
behind them when they go transparent.
That ordering is no longer what decides whether a failed install
leaves Home opaque, and the docstring used to claim it was.
``__init__`` clears the surfaces again unconditionally after
calling this, because there is never nothing behind them —
:meth:`paintEvent` paints :meth:`page_fill`. See the comment on
that second call for why the old "stay opaque on failure" rule was
the black-slab bug rather than a safety net.
"""
if self._ambient is not None:
return
widget = None
try:
from ..preferences import (get_ambient_enabled,
get_ambient_palette,
get_ambient_theme)
if not get_ambient_enabled():
return
from .ambient import (install_ambient,
_the_heavy_import_lock_is_free)
if not _the_heavy_import_lock_is_free():
from PySide6.QtCore import QTimer
QTimer.singleShot(120, self._install_ambient)
return
widget = install_ambient(
self, None,
theme=get_ambient_theme(),
palette=get_ambient_palette(),
backdrop=self._ambient_backdrop())
self._clear_page_surfaces()
self._ambient = widget
except Exception as error:
self._ambient = None
self._discard_ambient(widget)
try:
from .ambient import _the_backdrop_wants_a_retry
except Exception: # noqa: BLE001
return
if _the_backdrop_wants_a_retry(error):
from PySide6.QtCore import QTimer
QTimer.singleShot(120, self._install_ambient)
@staticmethod
def _ambient_backdrop():
"""The wallpaper the animation composites over, or ``None``.
Only the image themes have one; every other theme paints over its
own flat window colour.
"""
try:
from ..preferences import (resolve_effective_theme,
theme_background_path)
return theme_background_path(resolve_effective_theme())
except Exception:
return None
def _discard_ambient(self, widget=None) -> None:
"""Unparent an ambient widget an aborted install left behind.
``install_ambient`` parents the widget before it finishes wiring it
up, so an installer that raises part way through hands nothing back
to unparent — and an invisible leftover is still a child with a live
timer.
"""
try:
from .ambient import AmbientWidget
except Exception:
return
seen = []
if widget is not None:
seen.append(widget)
seen += [c for c in list(self.children())
if isinstance(c, AmbientWidget)]
for child in seen:
try:
child.set_animating(False)
except Exception:
pass
try:
child.setParent(None)
child.deleteLater()
except Exception:
pass
def _clear_page_surfaces(self) -> None:
"""Stop Home's layout containers painting over the backdrop.
The same layering rule the module screens use: containers that only
*position* things go transparent, while the cards that carry text —
the hero, the aside panels, the tile pane — keep painting a surface.
Without this the animation runs, costs its frames, and reaches the
eye through nothing but the gaps between widgets.
Everything that only POSITIONS things is tagged. That is most of the
page: every plain ``QWidget`` container inherits the blanket
``QWidget {{ background-color: bg }}`` rule, so an untagged one paints
an opaque slab whatever the opacity preference says — it is the window
colour, not a surface, which is why no amount of dialling reached it.
Not tagged, on purpose: the rounded panel boxes and the tiles. Those
are the things the user is meant to SEE, and they carry the page
opacity themselves.
"""
from ..theme import clear_container_surfaces, make_transparent
from PySide6.QtWidgets import (QLabel, QScrollArea, QStackedWidget,
QTabBar, QTabWidget)
clear_container_surfaces(self)
for bar in self.findChildren(QTabBar):
make_transparent(bar)
hero = self.findChild(QWidget, "Hero")
if hero is not None:
make_transparent(*hero.findChildren(QLabel))
make_transparent(*(w for w in (
getattr(self, "_running_host", None),
getattr(self, "_hint_bar", None),
getattr(self, "_tabs", None),
) if w is not None))
tabs = getattr(self, "_tabs", None)
if tabs is not None:
for area in tabs.findChildren(QScrollArea):
make_transparent(area, area.viewport())
make_transparent(*tabs.findChildren(QStackedWidget))
for i in range(tabs.count()):
page = tabs.widget(i)
if page is not None:
make_transparent(page)
layout = self.layout()
if layout is not None and layout.count():
item = layout.itemAt(0)
body = item.widget() if item is not None else None
if body is not None:
make_transparent(body)
def _new_running_banner(self) -> RunningBanner:
"""Build a banner for a run that has just started.
:returns: the banner.
"""
banner = RunningBanner(self._icon_provider, self._names)
banner.open_requested.connect(self.tile_clicked)
self._running_layout.addWidget(banner)
self._banners.append(banner)
return banner
def _refresh_run_banners(self) -> None:
"""Add and remove banners so they match the runs actually going.
RECONCILED RATHER THAN APPENDED: a run that ends while Home is not
visible leaves a banner claiming it is still running otherwise.
"""
for banner in self._banners:
if banner.isVisible():
banner.refresh()
def _grouping(
self,
given: Optional[Sequence[Tuple[str, Sequence[str]]]],
) -> List[Tuple[str, List[Tuple[str, str, str]]]]:
"""Normalise a caller's grouping into ``(title, [entry])``.
``None`` means "group ``apps`` by their own section, in the
order the sections first appear" — the pre-#16i behaviour, and
the only sensible default for the many tests that hand this
widget four hand-written tuples.
Unknown keys are dropped rather than raised on: a grouping is a
*view* of the registry, and a view that names an app that no
longer exists should lose the tile, not the page.
"""
if given is None:
grouped: Dict[str, List[Tuple[str, str, str]]] = {}
for key, name, desc, section in self._apps:
grouped.setdefault(section, []).append((key, name, desc))
return list(grouped.items())
out = []
for title, keys in given:
entries = [self._by_key[k] for k in keys if k in self._by_key]
if entries:
out.append((title, entries))
return out
def _build_hero(self) -> QWidget:
"""Build the masthead over the tiles."""
P = self._P
hero = QWidget()
hero.setObjectName("Hero")
try:
from ..preferences import resolve_effective_theme
from ..theme import RADIUS, pane_surface
hero.setStyleSheet(
f"QWidget#Hero {{"
f" background-color:"
f" {pane_surface('surface_alt', resolve_effective_theme())};"
f" border-radius: {RADIUS['lg']}px;"
f" }}")
except Exception: # noqa: BLE001
import logging
logging.getLogger(__name__).debug(
"the masthead would not take its surface", exc_info=True)
row = QHBoxLayout(hero)
row.setContentsMargins(SPACING["md"], SPACING["sm"],
SPACING["md"], SPACING["sm"])
row.setSpacing(SPACING["md"])
logo_px = font_px(HERO_LOGO_PX)
logo = _find_logo_pixmap()
if logo is not None:
label = QLabel()
label.setObjectName("HeroMark")
label.setFixedSize(logo_px, logo_px)
label.setStyleSheet("background: transparent;")
def _draw_mark(label=label, source=logo, side=logo_px):
"""Redraw the mark at the ratio of the screen it is on.
Always from the 3334 px master. Re-scaling whatever is
already on the label would compound one resample for every
screen the window has been dragged across.
"""
label.setPixmap(scaled_for(source, label, side))
_draw_mark()
follow_device_ratio(label, _draw_mark)
self._hero_mark = label
row.addWidget(label)
title = QLabel("spaCR")
title.setStyleSheet(
"font-family: 'Open Sans', sans-serif; font-weight: 300;"
f"font-size: {font_px(HERO_TITLE_PX)}px; color: {P['accent']};"
"letter-spacing: -0.6px; background: transparent;")
row.addWidget(title)
from .loading_screen import strap_line
subtitle = QLabel(strap_line())
subtitle.setObjectName("HeroSubtitle")
subtitle.setWordWrap(True)
row.addWidget(subtitle, 1)
return hero
def _build_tabs(self) -> QWidget:
"""Home (everything), then one tab per category.
Home is not a summary of the categories, it *is* every app —
which is what makes the categories optional rather than a
hierarchy you have to navigate. It bands them by the same
categories the tabs use, because thirty unlabelled tiles is a
wall and because two groupings of the same thirty apps is one
grouping too many.
The tab list is *derived*: a category with no members gets no
tab. That is not a special case to maintain, it is the reason
there is no empty pane to open when a category's last app moves
elsewhere. What counts as a member is the registry's business —
see ``categories``.
"""
self._tabs = QTabWidget()
self._tabs.setObjectName("HomeTabs")
self._tabs.setDocumentMode(False)
self._tabs.setStyleSheet(
_tab_qss(self._P, self._pane_alpha()))
from PySide6.QtWidgets import QStackedWidget
from ..theme import make_transparent
make_transparent(*self._tabs.findChildren(QStackedWidget))
self._section_names = [title for title, _e in self._categories]
from ..i18n import tr
self._tabs.addTab(self._build_home_tab(),
f"{tr('Home')} ({len(self._apps)})")
for section, entries in self._categories:
self._tabs.addTab(
self._build_category_tab(section, entries),
_escape_amp(f"{tr(section)} ({len(entries)})"))
return self._tabs
def _build_home_tab(self) -> QWidget:
"""Build the first tab: every module, grouped by band."""
from ..preferences import scaled_px
page = QWidget()
col = QVBoxLayout(page)
col.setContentsMargins(SPACING["md"], SPACING["sm"],
SPACING["md"], SPACING["sm"])
col.setSpacing(SPACING["xs"])
width = scaled_px(self.TILE_MIN_W)
for band, entries in self._bands:
col.addWidget(self._band_header(band, len(entries)))
holder = QWidget()
grid = QGridLayout(holder)
grid.setContentsMargins(0, 0, 0, SPACING["xs"])
grid.setHorizontalSpacing(SPACING["xs"])
grid.setVerticalSpacing(SPACING["xs"])
tiles = [self._make_tile(k, n, d) for k, n, d in entries]
self._grids.append((holder, grid, tiles, width))
self._fill_grid(grid, tiles,
self._columns_for(self.width(), width))
col.addWidget(holder)
col.addStretch(1)
return self._scrolled(page)
def _band_header(self, title: str, count: int) -> QWidget:
"""One band's heading and its one-line description.
:param title: the band's name.
:param count: how many modules it holds.
:returns: the header widget.
"""
P = self._P
wrap = QWidget()
col = QVBoxLayout(wrap)
col.setContentsMargins(0, 0, 0, 2)
col.setSpacing(3)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(SPACING["sm"])
label = QLabel(title.upper())
label.setStyleSheet(
"font-family: 'Open Sans', sans-serif; font-weight: 600;"
f"font-size: {font_px(11)}px; letter-spacing: 2px;"
"background: transparent;"
f"color: {P['fg_muted']};")
note = QLabel(str(count))
note.setStyleSheet(f"color: {P['fg_dim']}; font-size: {font_px(11)}px;"
"background: transparent;")
row.addWidget(label)
row.addWidget(note)
row.addStretch(1)
col.addLayout(row)
col.addWidget(Divider())
return wrap
def _build_category_tab(self, section: str,
entries: List[Tuple[str, str, str]]) -> QWidget:
"""Build one band's own tab.
:param section: the band to build.
:param entries: its modules.
:returns: the tab widget.
"""
from ..preferences import scaled_px
P = self._P
page = QWidget()
col = QVBoxLayout(page)
col.setContentsMargins(SPACING["md"], SPACING["sm"],
SPACING["md"], SPACING["sm"])
col.setSpacing(SPACING["xs"])
head = QWidget()
head_col = QVBoxLayout(head)
head_col.setContentsMargins(0, 0, 0, 0)
head_col.setSpacing(2)
from ..i18n import tr
heading = QLabel(tr(section).upper())
heading.setStyleSheet(
"font-family: 'Open Sans', sans-serif; font-weight: 600;"
f"font-size: {font_px(11)}px; letter-spacing: 2px;"
"background: transparent;"
f"color: {P['fg_muted']};")
head_col.addWidget(heading)
note = self._section_notes.get(section)
if note:
caption = QLabel(tr(note))
caption.setObjectName("HomeSectionNote")
caption.setWordWrap(True)
caption.setStyleSheet(
f"color: {P['fg_dim']}; font-size: {font_px(12)}px;"
"background: transparent;")
head_col.addWidget(caption)
col.addWidget(head)
col.addWidget(Divider())
holder = QWidget()
grid = QGridLayout(holder)
grid.setContentsMargins(0, SPACING["xs"], 0, 0)
grid.setHorizontalSpacing(SPACING["xs"])
grid.setVerticalSpacing(SPACING["xs"])
width = scaled_px(self.TILE_MIN_W)
tiles = [self._make_tile(k, n, d) for k, n, d in entries]
self._grids.append((holder, grid, tiles, width))
self._fill_grid(grid, tiles, self._columns_for(self.width(), width))
col.addWidget(holder)
col.addStretch(1)
return self._scrolled(page)
def _make_tile(self, key: str, name: str, desc: str) -> AppTile:
"""One tile. Same class, same size, on every tab."""
from ..preferences import scaled_px
icon = self._icon_provider(key) if self._icon_provider else None
tile = AppTile(name, desc, icon,
width=scaled_px(self.TILE_MIN_W),
height=scaled_px(self.TILE_H),
icon_px=scaled_px(self.TILE_ICON_PX),
stage=self._stages.get(key, "stable"))
tile.setMaximumWidth(scaled_px(self.TILE_MAX_W))
tile.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
return self._wire_tile(tile, key, desc)
def _wire_tile(self, tile, key: str, desc: str):
"""Connect one tile so pressing it opens its module.
:param tile: the tile.
:param key: the module it opens.
"""
self._tile_hints[tile] = (key, desc)
from ..theme import STAGE_LABEL
tile.setProperty("moduleAppKey", key)
tile.setProperty("moduleNameSource", tile.text_label)
tile.setProperty("moduleSummarySource", desc)
tile.setProperty("moduleTooltipStyle", "tile")
tile.setProperty("moduleStageSource", STAGE_LABEL.get(tile.stage, ""))
tile.installEventFilter(self)
tile.clicked.connect(lambda _=False, k=key: self.tile_clicked.emit(k))
return tile
def _scrolled(self, page: QWidget) -> QScrollArea:
"""Wrap a page in a scroll area.
:param page: the widget to wrap.
:returns: the scroll area.
"""
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
from ..theme import make_transparent
make_transparent(page, scroll)
scroll.viewport().setAutoFillBackground(False)
scroll.setWidget(page)
return scroll
@staticmethod
def _fill_grid(grid: QGridLayout, tiles: list, columns: int) -> None:
"""(Re)place ``tiles`` into ``columns`` columns, packed to the top.
Rows get zero stretch and one extra row takes all of it,
otherwise QGridLayout shares the leftover height between the rows
and the tiles drift apart down the page.
The tiles widen to their column, up to ``AppTile``'s own maximum,
so a row reaches both edges instead of leaving a ragged gap after
each tile — the difference between a page that reads as full and
one that reads as sparse. There used to be a second mode that
packed fixed-size cards to the left; every tile is the same class
now and every grid wants the same behaviour, so the flag went
with the second tile size.
"""
for tile in tiles:
grid.removeWidget(tile)
rows = 0
for index, tile in enumerate(tiles):
rows = index // columns
grid.addWidget(tile, rows, index % columns)
for row in range(grid.rowCount()):
grid.setRowStretch(row, 0)
grid.setRowStretch(rows + 1, 1)
span = max(grid.columnCount(), columns)
for column in range(span):
grid.setColumnStretch(column, 1 if column < columns else 0)
def _columns_for(self, width: int, tile_w: int) -> int:
"""How many ``tile_w``-wide tiles fit beside the aside.
Recomputed on resize so a narrow window rewraps instead of
growing a horizontal scrollbar.
"""
from ..preferences import scaled_px
aside = getattr(self, "_aside", None)
if aside is None:
aside_w = scaled_px(self.ASIDE_W)
elif aside.isHidden() or self._aside_collapsed():
aside_w = 0
else:
aside_w = max(aside.width(), aside.minimumWidth())
available = max(1, width - aside_w
- SPACING["xl"] * 2 - SPACING["lg"]
- SPACING["md"] * 2 - 4)
return max(1, available // (tile_w + SPACING["xs"]))
@staticmethod
def _pane_alpha() -> float:
"""Opacity of the rounded box behind the tiles.
The user's ``pane_opacity`` preference, already clamped up to the
theme's legibility floor by
:func:`spacr.qt.preferences.effective_pane_alpha`. Falls back to
fully opaque — what the page looked like before the preference
existed — if preferences cannot be read at all.
"""
try:
from ..preferences import effective_pane_alpha
return effective_pane_alpha()
except Exception:
return 1.0
def _build_aside(self) -> QWidget:
"""Build the right-hand column: status panels, then the text slider.
The column is an EDGE pane of Home's splitter, the same handle the
module screens give their settings column: drag it to resize, click
its arrow to hide or show the column; both are remembered. The panels
and the slider under the lowest one sit in a scroll area, so large
text scrolls instead of squeezing a card.
"""
from ..preferences import _KEY_HOME_ASIDE_TEXT, _home_aside_scale
from ..theme import make_transparent
aside = QWidget()
aside.setObjectName("HomeAside")
make_transparent(aside)
self._aside = aside
outer = QVBoxLayout(aside)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
panels = QWidget()
panels.setObjectName("HomeAsidePanels")
self._aside_panels = panels
col = QVBoxLayout(panels)
col.setContentsMargins(0, 0, 0, 0)
col.setSpacing(SPACING["md"])
from ..i18n import tr
start = QPushButton(tr("Pipeline overviews"), panels)
start.setObjectName("PrimaryButton")
start.setToolTip(tr(
"Explore pipeline flowcharts, their modules, inputs and outputs, "
"and optionally start with example data."))
start.clicked.connect(
lambda _checked=False: self.sample_project_requested.emit())
self._sample_project_button = start
col.addWidget(start)
self._queued = QueuedPanel()
self._recent = RecentRunsPanel(known_keys=lambda: self._names,
read_now=False)
self._recent.run_clicked.connect(self._on_run_clicked)
self._recent.cleared.connect(self.refresh)
self._news = NewsPanel(self._version())
self._news.check_requested.connect(self.update_check_requested)
self._news.refresh_requested.connect(self.news_refresh_requested)
self._totals = TotalsPanel(read_now=False)
self._system = SystemPanel()
self._legend = StageLegend(self)
self._legend.hide()
for panel in (self._queued, self._recent, self._news,
self._totals, self._system):
col.addWidget(panel)
self._aside_text = _home_aside_scale(
_KEY_HOME_ASIDE_TEXT, *_ASIDE_TEXT_RANGE)
col.addWidget(self._build_aside_slider())
col.addStretch(1)
scroll = self._scrolled(panels)
scroll.setObjectName("HomeAsideScroll")
self._aside_scroll = scroll
outer.addWidget(scroll, 1)
self._fit_aside_minimum()
return aside
def _build_aside_slider(self) -> QWidget:
"""The compact Text size slider under the lowest panel."""
from PySide6.QtWidgets import QSlider
from ..i18n import tr
box = QWidget()
box.setObjectName("HomeAsideScaleControls")
make_transparent(box)
self._aside_controls = box
row = QHBoxLayout(box)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(SPACING["sm"])
tip = tr("Make the text in this column's panels larger or smaller. "
"The setting is remembered. Default 100%.")
P = self._P
label = QLabel(tr("Text size"), box)
label.setStyleSheet(f"color: {P['fg_muted']};"
f" font-size: {font_px(11)}px;"
" background: transparent;")
slider = QSlider(Qt.Horizontal, box)
slider.setObjectName("HomeAsideTextSlider")
slider.setRange(int(round(_ASIDE_TEXT_RANGE[0] * 100)),
int(round(_ASIDE_TEXT_RANGE[1] * 100)))
slider.setSingleStep(5)
slider.setPageStep(10)
slider.setValue(int(round(self._aside_text * 100)))
readout = QLabel(f"{slider.value()}%", box)
readout.setProperty("i18nSkipText", True)
readout.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
readout.setStyleSheet(label.styleSheet())
readout.setMinimumWidth(QLabel("200%").sizeHint().width())
for widget in (label, slider, readout):
widget.setToolTip(tip)
slider.valueChanged.connect(lambda v: readout.setText(f"{v}%"))
slider.valueChanged.connect(self._on_aside_text)
row.addWidget(label)
row.addWidget(slider, 1)
row.addWidget(readout)
self._homeAsideTextSlider = slider
return box
def _aside_collapsed(self) -> bool:
"""Whether the right-hand column is folded away by its handle."""
split = getattr(self, "_aside_split", None)
return bool(split is not None and split.is_collapsed(_ASIDE_PANE))
def _on_aside_text(self, percent: int) -> None:
"""Resize the right-hand column's text and remember the size."""
from ..preferences import _KEY_HOME_ASIDE_TEXT, _set_home_aside_scale
self._aside_text = _set_home_aside_scale(
_KEY_HOME_ASIDE_TEXT, percent / 100.0, *_ASIDE_TEXT_RANGE)
self._apply_aside_text()
def _apply_aside_text(self) -> None:
"""Give the panels' text the stored size, then refit the width.
Two passes, because the panels take their sizes from two places:
the window's sheet, re-declared on the panel host at the new size by
:func:`spacr.qt.live_zoom.scaled_font_sheet` (the same pass the
module screens' Ctrl + wheel uses), and each label's own sheet, whose
sizes are rewritten from the size it was built with. Panels rebuild
rows on refresh, so this runs again after every refresh.
"""
panels = getattr(self, "_aside_panels", None)
if panels is None:
return
if self._aside_text == 1.0 and not getattr(
self, "_aside_text_applied", False):
return
self._aside_text_applied = True
_scale_text_under(panels, self._aside_text,
exempt=getattr(self, "_aside_controls", None))
self._fit_aside_minimum()
def _fit_aside_minimum(self) -> None:
"""Keep the column at least as wide as its content needs.
Large text raises the floor, so a row widens the column instead of
clipping; dragging the handle past the floor folds the column away,
as it does the module screens' settings column.
"""
aside = getattr(self, "_aside", None)
panels = getattr(self, "_aside_panels", None)
if aside is None or panels is None:
return
layout = panels.layout()
layout.activate()
floor = layout.totalMinimumSize().width()
scroll = getattr(self, "_aside_scroll", None)
if scroll is not None:
floor += scroll.verticalScrollBar().sizeHint().width()
if aside.minimumWidth() != floor:
aside.setMinimumWidth(floor)
split = getattr(self, "_aside_split", None)
pane = split.pane(_ASIDE_PANE) if split is not None else None
if pane is not None:
pane.minimum = floor
self._rewrap_grids()
def _rewrap_grids(self) -> None:
"""Re-flow every tile grid for the page's width and the column's."""
for _holder, grid, tiles, tile_w in self._grids:
self._fill_grid(grid, tiles,
self._columns_for(self.width(), tile_w))
def _on_run_clicked(self, key: str) -> None:
"""Open the module a Recent runs row names, if it still exists.
The panel already filters to modules Home knows about, so this is
the second of two guards rather than the only one. It is here
because the filter depends on `known_keys` resolving, and a signal
that navigates has to be safe when it does not: a row for a module
that is not registered used to ask the window to open `_job`.
"""
if key and key in self._names:
self.tile_clicked.emit(key)
@property
[docs]
def legend(self) -> "StageLegend":
"""The colour-to-maturity key at the foot of the right column."""
return self._legend
@staticmethod
def _version() -> str:
"""The version string shown on the masthead.
:returns: the version.
"""
try:
import spacr
version = str(getattr(spacr, "__version__", "") or "").strip()
except Exception:
return ""
return "" if version.lower() in ("", "dev", "unknown") else version
def _on_runs_changed(self) -> None:
"""Show every active job across the top, oldest first."""
if self._closing:
return
active = [h for h in self._registry.active()
if h.app_key and getattr(h, "user_visible", True)]
while len(self._banners) < len(active):
self._new_running_banner()
for index, banner in enumerate(self._banners):
banner.bind(active[index] if index < len(active) else None)
if not active:
self._ticker.stop()
elif not self._ticker.isActive():
self._ticker.start()
[docs]
def refresh(self) -> None:
"""Re-read everything that can change while Home is off screen.
The two run-journal panels are read on a worker thread. Together
``recent_runs`` + ``journal_totals`` walk every manifest under the
runs root twice — 774 ms on a machine with 4 865 journalled runs,
measured, and it grows with the journal — and this used to run inline
on every single return to Home, which is the most-travelled
navigation in the application.
The panels keep whatever they are already showing until the worker
delivers; a stale count for half a second beats a frozen window, and
on the first ever call they are showing their empty state anyway.
Everything else here is cheap (a JSON read and three stat calls) and
stays inline.
"""
self._queued.refresh()
self._system.refresh()
self._on_runs_changed()
self._apply_aside_text()
self._read_the_journal()
def _read_the_journal(self) -> None:
"""Read Recent runs and Totals on a worker; :meth:`_apply_journal`
paints them. Home is built with both panels empty and filled from
here, so the window never waits for the run journal."""
recent, totals = self._recent, self._totals
self._journal_jobs.cancel()
self._journal_jobs.submit(
lambda r=recent, t=totals: (r.read(), t.read()),
self._apply_journal)
def _apply_journal(self, payload) -> None:
"""Paint the worker's journal read. GUI thread only."""
runs, totals = payload
self._recent.refresh(runs)
self._totals.refresh(totals)
self._apply_aside_text()
[docs]
def active_jobs(self) -> int:
"""How many journal-reading threads are still winding down."""
return self._journal_jobs.active_jobs()
[docs]
def set_reserved_content(self, widget: QWidget) -> None:
"""Fill the featured/news surface with real content.
:param widget: the widget to show in the featured/news panel in place
of the release notes.
"""
self._news.set_content(widget)
self._apply_aside_text()
[docs]
def apply_release_news(self, releases) -> None:
"""Hand a fetched release list to the News panel.
The answer to :attr:`news_refresh_requested`, and the only way in:
the window never reaches into the panel, so a page rebuilt at a new
font scale simply asks again.
:param releases: records from
:func:`spacr.updater.fetch_release_notes`, or anything at all.
"""
self._news.apply_releases(releases)
self._apply_aside_text()
@property
[docs]
def news_panel(self) -> "NewsPanel":
"""The News panel. For tests and for the window's own wiring."""
return self._news
@property
def _reserved_content(self) -> Optional[QWidget]:
"""The widget currently filling the news surface, if any."""
return self._news.content
[docs]
def resizeEvent(self, event): # noqa: N802
"""Re-flow the tile grid for the new width.
:param event: the Qt resize event.
"""
super().resizeEvent(event)
self._rewrap_grids()
[docs]
def show_module_hint(self, key: str, summary: str = "") -> bool:
"""Explain ``key`` in the strip. Called by the DOCK as well as Home.
The dock's rows and Home's tiles name the same modules, so they say
the same thing in the same place -- `MainWindow._show_module_hint`
routes a dock hover here whenever Home is the page on screen.
:param key: the module to explain.
:param summary: the sentence, already resolved and translated by the
caller. Empty falls back to Home's own registry, which is what a
tile hover uses -- it has the description in hand and has no
reason to ask the window for it.
:returns: whether anything was written. A key Home does not know is
not an error: the dock lists Help modules that have no tile.
"""
key = str(key or "")
from ..theme import STAGE_LABEL
if not summary:
entry = self._by_key.get(key)
if entry is None:
return False
from ..i18n_module_summaries import module_summary
summary = module_summary(key, entry[2])
if not summary:
return False
stage = STAGE_LABEL.get(str(self._stages.get(key, "stable")), "")
self._hint_bar.show_module(key, summary, stage)
return True
[docs]
def eventFilter(self, obj, event): # noqa: N802
"""Watch the widgets this filter is installed on.
:param obj: the object the event is for.
:param event: the event.
:returns: True to stop the event going further.
"""
if event.type() == QEvent.Enter:
hint = self._tile_hints.get(obj)
if hint:
from ..i18n_module_summaries import module_summary
from ..theme import STAGE_LABEL
key, source = hint
summary = module_summary(key, source)
mark = STAGE_LABEL.get(
str(obj.property("stage") or "stable"), "")
self._hint_bar.show_module(key, summary, mark)
elif event.type() == QEvent.Leave:
if not self._hint_bar.is_holding():
self._hint_bar.release()
return super().eventFilter(obj, event)
[docs]
def closeEvent(self, event): # noqa: N802
"""Stop Home-page background activity before closing.
Shut down the journal reader, disconnect run-registry notifications,
and stop the refresh ticker before delegating to the base close
handler. This prevents pending work from invoking a page that Qt is
destroying.
:param event: the close event; it is passed on to the base class after
background activity stops.
"""
self._closing = True
self._journal_jobs.shutdown()
try:
self._registry.changed.disconnect(self._on_runs_changed)
except (RuntimeError, TypeError):
pass
self._ticker.stop()
super().closeEvent(event)
def _tab_qss(P: dict, pane_alpha: float = 1.0) -> str:
"""Return styling for the transparent Home tab container.
``pane_alpha`` controls only the selected tab's surface fill; the pane and
tab-bar backgrounds remain transparent. The pane has no decorative rim;
the selected tab's own edge is the meaningful state indicator.
"""
from ..theme import css_color
selected_fill = ("transparent" if pane_alpha <= 0.0
else css_color(P["surface"], pane_alpha))
return f"""
QTabWidget#HomeTabs::pane {{
border: none;
background: transparent;
top: -1px;
}}
/* The BAR, not the tabs on it. Qt builds `qt_tabwidget_tabbar` itself, and
with no rule of its own it takes the blanket window fill — measured as the
last opaque strip on the page after everything else was cleared. Tagging
the widget is not enough: the stylesheet wins over the property for this
one, so it needs saying here. */
QTabWidget#HomeTabs > QTabBar {{
background: transparent;
}}
QTabWidget#HomeTabs > QTabBar::tab {{
background: transparent;
color: {P['fg_muted']};
border: 1px solid transparent;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
padding: 7px 14px;
margin-right: 2px;
font-size: {font_px(13)}px;
}}
QTabWidget#HomeTabs > QTabBar::tab:hover {{
color: {P['fg']};
background: {P['surface_alt']};
}}
/* The selected tab takes the page opacity like everything else. Its own edge
carries selection without drawing a decorative rim around the empty pane. */
QTabWidget#HomeTabs > QTabBar::tab:selected {{
color: {P['accent']};
background: {selected_fill};
border: 1px solid {P['border_soft']};
border-bottom-color: {selected_fill};
}}
"""