"""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 " ".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))