Source code for spacr.qt.widgets.module_hint_bar

"""The strip along the bottom that explains the module under the pointer.

The strip replaces the popup tooltip on the module tiles, and carries an API
link and a tutorial link beside the description.

THE HOLD IS THE WHOLE POINT, and it is the same argument that shaped the
per-setting strip: a link that appears only while the pointer is on the tile
is a link that cannot be clicked, because moving toward it removes it. So the
strip keeps the LAST module hovered for thirty seconds -- long enough to
notice it, cross the window and press a word.

Thirty seconds rather than the per-setting strip's ten, because these two
links leave the application. Ten seconds is a budget for reaching a word; a
reader deciding whether to open documentation or a lesson in a browser is
making a larger decision.

TWO SURFACES, ONE BAR. Home's tiles and the dock's rows both write here, and
so does a module screen's own strip when the dock is hovered over it -- see
:meth:`spacr.qt.app.MainWindow._show_module_hint`, which routes to whichever
bar is on screen. A module explained differently depending on where you
pointed at it would be two explanations to maintain.
"""
from __future__ import annotations

from html import escape
from typing import Optional

from PySide6.QtCore import QEvent, Qt, QTimer, Signal
from PySide6.QtWidgets import QLabel, QWidget

#: What the strip says when nothing has been hovered yet.
DEFAULT_HINT = "Hover a tile to see what it does."

#: The objectName the stylesheet selects on. Shared with the plain bar this
#: replaces so the two are one thing wearing one style.
BAR_NAME = "HintBar"


[docs] class ModuleHintBar(QLabel): """A module's summary, its API link and its Tutorial link, held 30 s. :param default: what the strip says with nothing hovered. Restored when the hold expires, so it should read as a prompt rather than a blank. :param parent: parent widget. """ #: Emitted with the app key when a link is followed, so a caller can #: log or intercept. The bar opens the URL itself either way. link_followed = Signal(str, str) #: How long the strip keeps the last module hovered, in milliseconds. #: #: THE NUMBER WAS ASKED FOR: "shown at the botom for 30 seconds". It is #: the budget for noticing the strip, deciding, crossing the window and #: pressing a word that opens a browser -- not a value to tune down #: because it reads as long in source. HOLD_MS = 30_000 def __init__(self, default: str = DEFAULT_HINT, parent: Optional[QWidget] = None) -> None: """Build the module help strip under the dock. The height is fixed, and that is load-bearing rather than tidy: a strip that grows when a long summary wraps relayouts the page under the pointer, and the dock is in that layout -- which is the row moving out from under the pointer and back, delivering an Enter and a Leave each time and leaving a dock row stuck highlighted. Two lines, measured from the font rather than pinned at a number, because a hard number is a promise about text metrics that breaks the moment the scale or the theme's font stack changes. :param default: what to show with nothing hovered. :param parent: parent widget, or ``None``. """ super().__init__(default, parent) self._default = default self._key = "" self._timer: Optional[QTimer] = None self.setObjectName(BAR_NAME) self.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) self.setTextFormat(Qt.RichText) self.setOpenExternalLinks(True) self.setTextInteractionFlags(Qt.TextBrowserInteraction) self.linkActivated.connect(self._on_link) line = max(1, self.fontMetrics().lineSpacing()) self.setFixedHeight(line * 2 + 10) self.setWordWrap(False)
[docs] def event(self, event): # noqa: N802 - Qt naming """Swallow tooltip requests. THIS BAR IS THE TOOLTIP. A popup appearing over this bar is a bug, and not one this bar causes: nothing here asks for one -- the bar sets no tooltip on itself or on anything in it. Qt PROPAGATES an unhandled ``QEvent.ToolTip`` up the parent chain, and :func:`spacr.qt.module_hints.install_module_hints` filters the whole application, so the request walked up from this label to an ancestor carrying ``moduleAppKey`` and that ancestor's popup appeared over the strip that exists to replace popups. Accepting the event here stops the walk at the bar. The strip keeps its own behaviour -- it is still written by every hover elsewhere, and its API and tutorial links still work -- but hovering the strip itself now shows nothing, which is what it already looked like it promised. :param event: any event sent to the bar; a ``QEvent.ToolTip`` is accepted and consumed, everything else goes to the base class. """ if event.type() == QEvent.Type.ToolTip: event.accept() return True return super().event(event)
@property
[docs] def module_key(self) -> str: """The module the strip is currently explaining, or ``""``.""" return self._key
[docs] def show_module(self, key: str, summary: str, stage: str = "") -> str: """Explain ``key``, with its links, and start the hold. :param key: the module's app key. Both links are derived from it. :param summary: the sentence to show, already in the UI language. :param stage: an optional maturity word appended to the summary. It rides here because a tile's hover HUE cannot carry it alone -- colour by itself fails WCAG 1.4.1, and a colour-blind sighted reader reads neither the hue nor the accessibility tree. :returns: the rich text written, so a test can read it back. """ text = str(summary or "").strip() mark = str(stage or "").strip() self._key = str(key or "") suffix = f" — {mark}" if mark else "" html = escape(self._fit(text, reserve=suffix) + suffix) links = self._links_html(self._key) if links: html = f"{html}<br>{links}" if html else links self.setText(html) self.setAccessibleDescription(text) self._hold(True) return html
def _fit(self, text: str, reserve: str = "") -> str: """``text`` shortened to the one line the strip has for it. Measured against the font Qt is actually painting and the width the strip actually has, so it stays correct at any font scale rather than at the one this was written on. A strip with no width yet -- asked before it is laid out -- gets the text back untouched, because eliding to nothing would be worse than a first paint that is long. :param reserve: text that will be appended AFTER this returns, whose width is taken out of the room first. Without it the caller elides to the full width and then makes the line longer, which is how the maturity word ended up off the end of the strip. """ from PySide6.QtCore import Qt as _Qt metrics = self.fontMetrics() room = self.width() - 16 - (metrics.horizontalAdvance(reserve) if reserve else 0) if room <= 0: return text return metrics.elidedText(text, _Qt.ElideRight, room) def _links_html(self, key: str) -> str: """``API`` and ``Tutorial`` as anchors, whichever of them resolve. Short words on purpose, the same shortening the per-setting strip uses. The long forms repeat on every module and the strip is only a few lines tall. A word is drawn only where its target exists. Every registry module has a lesson today -- measured, 36 of 36 -- but a new module lands in the registry before it lands in the lesson catalog, and a Tutorial word that goes to an index of seventy-three lessons is worse than no word at all. """ from ..i18n import tr from ..tutorials import tutorial_url parts = [] api = self._api_url(key) if api: parts.append(f'<a href="{escape(api, quote=True)}">' f'{escape(tr("API"))}</a>') lesson = tutorial_url(key) if lesson: parts.append(f'<a href="{escape(lesson, quote=True)}">' f'{escape(tr("Tutorial"))}</a>') return "&nbsp;&nbsp;".join(parts) @staticmethod def _api_url(key: str) -> str: """``key``'s module page in the API documentation, or ``""``. Imported inside the call because `settings_model` is a large module and this one is reached from a hover handler on the startup page. """ if not key: return "" try: from ..screens.settings_model import api_docs_url return api_docs_url(key) except Exception: # noqa: BLE001 return ""
[docs] def release(self) -> None: """Put the default prompt back and stop holding.""" self._key = "" self._hold(False) from ..i18n import tr self.setText(escape(tr(self._default))) self.setAccessibleDescription(tr(self._default))
[docs] def is_holding(self) -> bool: """Whether the strip is keeping a module. For tests.""" return bool(self._timer is not None and self._timer.isActive())
def _hold(self, holding: bool) -> None: """Start, restart or stop the thirty-second hold. RESTARTED ON EACH NEW MODULE, so reading across a row of tiles is not a race against a clock started by the first one. Stopped outright when the strip is being put back to its default, or the timer would blank a strip that is already the prompt. """ if self._timer is None: timer = QTimer(self) timer.setSingleShot(True) timer.timeout.connect(self.release) self._timer = timer self._timer.stop() if holding: self._timer.start(self.HOLD_MS) def _on_link(self, href: str) -> None: """Re-emit a followed link with the module it belongs to. :param href: the link that was activated. """ self.link_followed.emit(self._key, str(href))