Source code for spacr.qt.widgets.model_card_viewer

"""Viewer for the 3-page model cards of the Model Zoo.

Shows the page image of a card scaled to fit, with Previous and Next, a
"1 / 3" counter, Left and Right keys and a link to the model on Hugging Face.
The images are fetched on first view in a worker thread and cached; without
them the viewer shows the same three pages as text from the card data that
ships with spaCR. Clicking the image opens it larger in a dialog.
"""
from __future__ import annotations

import html
import threading
from typing import Any, Callable, Dict, List, Optional

from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import (QDialog, QFrame, QHBoxLayout, QLabel,
                               QPushButton, QSizePolicy, QStackedLayout,
                               QTextBrowser, QVBoxLayout)

from ... import model_cards as cards
from ..hidpi import scaled_for
from ..i18n import tr
from ..theme import RADIUS, SPACING, active_palette

VIEWER_NAME = "ModelCardViewer"


class _PageLabel(QLabel):
    """A label that keeps its pixmap scaled to its size, aspect kept."""

    clicked = Signal()

    def __init__(self, parent=None):
        """An empty label, centred, that any layout may resize.

        :param parent: parent widget.
        """
        super().__init__(parent)
        self._source: Optional[QPixmap] = None
        self.setAlignment(Qt.AlignCenter)
        self.setMinimumSize(160, 120)
        self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)

    def set_source(self, pixmap: Optional[QPixmap]) -> None:
        """Show ``pixmap`` (full resolution) scaled to fit."""
        self._source = pixmap
        self._rescale()

    def _rescale(self) -> None:
        """Fit the source pixmap to the label's current size."""
        if self._source is None or self._source.isNull():
            self.clear()
            return
        self.setPixmap(scaled_for(self._source, self,
                                  max(1, self.width()), max(1, self.height())))

    def resizeEvent(self, event):  # noqa: N802
        """Refit the pixmap to the new size.

        :param event: the Qt resize event.
        """
        super().resizeEvent(event)
        self._rescale()

    def mouseReleaseEvent(self, event):  # noqa: N802
        """Emit :attr:`clicked` on a left click.

        :param event: the Qt mouse event.
        """
        if event.button() == Qt.LeftButton:
            self.clicked.emit()
        super().mouseReleaseEvent(event)


[docs] class ModelCardViewer(QFrame): """Page through the card of one model. :param parent: parent widget. """ #: Emitted with the zero-based page after every page change. page_changed = Signal(int) _fetched = Signal(str, object) def __init__(self, parent=None, *, threaded: bool = True, fetch: Optional[Callable[[str], bytes]] = None, enlargeable: bool = True): """:param threaded: fetch the images on a worker thread; ``False`` fetches inline, for tests. :param fetch: byte source taking a URL, handed to :func:`spacr.model_cards.fetch_pages`; ``None`` uses the network. :param enlargeable: clicking the image opens a larger dialog. """ super().__init__(parent) self.setObjectName(VIEWER_NAME) self.setFocusPolicy(Qt.StrongFocus) self._threaded = threaded self._fetch = fetch self._enlargeable = enlargeable self._card: Optional[Dict[str, Any]] = None self._pixmaps: List[Optional[QPixmap]] = [None] * cards.PAGE_COUNT self._page = 0 self._failed = False self._dialog: Optional[QDialog] = None self._thread: Optional[threading.Thread] = None self._fetched.connect(self._on_fetched) outer = QVBoxLayout(self) outer.setContentsMargins(SPACING["xs"], SPACING["xs"], SPACING["xs"], SPACING["xs"]) outer.setSpacing(SPACING["xs"]) self._stack = QStackedLayout() self._image = _PageLabel(self) self._image.clicked.connect(self._enlarge) self._text = QTextBrowser(self) self._text.setObjectName("ModelCardText") self._stack.addWidget(self._image) self._stack.addWidget(self._text) outer.addLayout(self._stack, 1) self._status = QLabel("", self) self._status.setObjectName("Muted") self._status.setWordWrap(True) outer.addWidget(self._status) row = QHBoxLayout() row.setSpacing(SPACING["sm"]) row.setContentsMargins(0, 0, 0, 0) self._prev = QPushButton(tr("Previous"), self) self._next = QPushButton(tr("Next"), self) for button in (self._prev, self._next): button.setFixedHeight(26) self._prev.clicked.connect(self.previous_page) self._next.clicked.connect(self.next_page) self._counter = QLabel("", self) self._counter.setAlignment(Qt.AlignCenter) self._link = QLabel("", self) self._link.setOpenExternalLinks(True) self._link.setTextInteractionFlags(Qt.TextBrowserInteraction) row.addWidget(self._prev) row.addWidget(self._counter, 1) row.addWidget(self._next) row.addSpacing(SPACING["sm"]) row.addWidget(self._link) outer.addLayout(row) self._apply_style() self._show_page()
[docs] def card(self) -> Optional[Dict[str, Any]]: """The card data being shown, or ``None``.""" return self._card
[docs] def page(self) -> int: """The zero-based current page.""" return self._page
[docs] def counter_text(self) -> str: """The "1 / 3" text.""" return self._counter.text()
[docs] def showing_image(self) -> bool: """True when the current page is the picture, not the text.""" return self._stack.currentWidget() is self._image
[docs] def status_text(self) -> str: """The line under the page.""" return self._status.text()
[docs] def set_card(self, card: Optional[Dict[str, Any]]) -> None: """Show ``card`` from page 1, fetching its images when not cached. :param card: the card data, or None to show nothing. """ self._card = card self.setAccessibleName(str(card.get("display_name", "")) if card else "") self._page = 0 self._failed = False self._pixmaps = [None] * cards.PAGE_COUNT if card is None: self._show_page() return repo = str(card["repo_id"]) cached = cards.cached_pages(repo) if all(cached): self._load(cached) else: self._status.setText(tr("Loading the card images…")) self._start_fetch(repo) self._show_page()
[docs] def go_to(self, page: int) -> None: """Show page ``page`` (zero-based), clamped to the pages. :param page: the page index, from 0. """ page = max(0, min(cards.PAGE_COUNT - 1, int(page))) if page != self._page: self._page = page self._show_page() self.page_changed.emit(page)
[docs] def next_page(self) -> None: """Show the next page; stays on the last.""" self.go_to(self._page + 1)
[docs] def previous_page(self) -> None: """Show the previous page; stays on the first.""" self.go_to(self._page - 1)
[docs] def keyPressEvent(self, event): # noqa: N802 """Left and Right turn the page. :param event: the Qt key event. """ if event.key() == Qt.Key_Right: self.next_page() elif event.key() == Qt.Key_Left: self.previous_page() elif (event.key() in (Qt.Key_Return, Qt.Key_Enter, Qt.Key_Space) and (event.modifiers() == Qt.NoModifier or (event.key() == Qt.Key_Enter and event.modifiers() == Qt.KeypadModifier)) and self.hasFocus()): self._enlarge() else: super().keyPressEvent(event)
def _start_fetch(self, repo: str) -> None: """Fetch the page images of ``repo``, on a worker when threaded. :param repo: ``owner/repo`` of the model. """ def job(): """Fetch the card pages and emit the outcome, or the failure.""" try: result: Any = cards.fetch_pages(repo, self._fetch) except Exception as exc: # noqa: BLE001 - any failure is offline result = exc try: self._fetched.emit(repo, result) except RuntimeError: return if not self._threaded: job() return self._thread = threading.Thread(target=job, daemon=True) self._thread.start() def _on_fetched(self, repo: str, result: Any) -> None: """Take the fetched pages, or note the failure, for the shown card. :param repo: the repo the fetch was for; a stale one is ignored. :param result: the page paths, or the exception the fetch raised. """ if not self._card or self._card.get("repo_id") != repo: return if isinstance(result, BaseException): self._failed = True else: self._load(list(result)) self._show_page() def _load(self, paths) -> None: """Read the page images; one that does not load fails them all. :param paths: the page image files, in page order. """ self._pixmaps = [QPixmap(str(p)) for p in paths] if any(p.isNull() for p in self._pixmaps): self._pixmaps = [None] * cards.PAGE_COUNT self._failed = True else: self._failed = False def _show_page(self) -> None: """Show the current page: its image, else its text from the card.""" card = self._card self._prev.setEnabled(card is not None and self._page > 0) self._next.setEnabled( card is not None and self._page < cards.PAGE_COUNT - 1) dots = " ".join("●" if i == self._page else "○" for i in range(cards.PAGE_COUNT)) self._counter.setText( f"{dots} {self._page + 1} / {cards.PAGE_COUNT}" if card else "") if card is None: self._link.setText("") self._image.set_source(None) self._text.setHtml("") self._stack.setCurrentWidget(self._text) return self._link.setText( f'<a href="{cards.model_link(card)}" ' f'style="color:{active_palette()["accent"]}">' f'{tr("Open on Hugging Face")}</a>') pixmap = self._pixmaps[self._page] if pixmap is not None: self._image.set_source(pixmap) self._stack.setCurrentWidget(self._image) self._status.setText("") self._image.setCursor( Qt.PointingHandCursor if self._enlargeable else Qt.ArrowCursor) self._image.setToolTip( tr("Click to enlarge") if self._enlargeable else "") return if self._failed: self._status.setText( tr("The card images could not be downloaded. Showing the " "card data stored with spaCR.")) headers = (tr("Score"), tr("This model (stock base)"), tr("Fact"), tr("Value")) palette = active_palette() self._text.setHtml( f"<body style='color:{palette['fg']}'>" f"<h3>{html.escape(str(card.get('display_name', '')))}</h3>" + cards.page_html(card, self._page + 1, headers) + "</body>") self._stack.setCurrentWidget(self._text) def _apply_style(self) -> None: """Style the frame, labels and text from the active palette.""" p = active_palette() self.setStyleSheet( f"QFrame#{VIEWER_NAME} {{ border: 1px solid {p['border_soft']};" f" border-radius: {RADIUS['md']}px; }}" f"QFrame#{VIEWER_NAME}:focus {{ border-color: {p['accent']}; }}" f"QFrame#{VIEWER_NAME} QLabel {{ background: transparent;" f" color: {p['fg']}; }}" f"QTextBrowser#ModelCardText {{ background: transparent;" f" border: none; }}") def _enlarge(self) -> None: """Open the current page larger in a dialog of its own.""" if not self._enlargeable or self._card is None: return dialog = QDialog(self) dialog.setWindowTitle(str(self._card.get("display_name", ""))) layout = QVBoxLayout(dialog) big = ModelCardViewer(dialog, threaded=False, fetch=self._fetch, enlargeable=False) layout.addWidget(big) big.set_card(self._card) big.go_to(self._page) dialog.resize(1000, 800) self._dialog = dialog dialog.show()