"""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 strip holds its text, for the same reason as 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 write to 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. So a module has one explanation wherever it is
pointed at.
"""
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: 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 fixed pixel number would be wrong when 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)
self.setAccessibleName(default)
[docs]
def event(self, event): # noqa: N802 - Qt naming
"""Consume tooltip requests; this bar replaces 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
shows no popup.
: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.setAccessibleName(text or self._default)
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.setAccessibleName(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))