"""Labels with hover-accessible links to spaCR API documentation."""
from __future__ import annotations
from html import escape
from typing import Optional
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QFontMetrics
from PySide6.QtWidgets import QLabel, QWidget
[docs]
class ApiHelpLabel(QLabel):
"""Display descriptive text with an API link in its hover help.
The link follows the active module and interface language. Labels without
an application key retain their description but omit the documentation
link. Hover content is also exposed through the Qt accessibility tree.
IT ELIDES RATHER THAN CLIPS, which is the difference between a sentence
the reader can SEE is unfinished and one that simply stops. This label is
the module masthead's blurb, and the masthead deliberately keeps it to
one line that "may shrink below its ideal width rather than force the
window wider" -- so being cut short is designed, and the hover help is
where the rest is meant to live.
A `wordWrap(False)` QLabel gives none of that away: Qt paints as many
characters as fit and stops, with no ellipsis and no hint there is more.
Measured on Classify in German, where the blurb is 1,354 px of text on
a 1,281 px line -- 73 px, about six
characters, gone without a mark. English fits, which is why it was
invisible until a second locale was measured.
:param text: the blurb to show, and what the label falls back to when no
translation is loaded.
:param app_key: which module's API page the link opens. Empty means no
link at all, which is how a caption outside a module screen is drawn.
:param parent: parent widget.
"""
def __init__(self, text: str = "", app_key: str = "",
parent: Optional[QWidget] = None):
"""Build a label whose hover help links into the module's API docs.
:param text: the description shown, and the body of the help.
:param app_key: the module the help links to; without one the label
still shows its description but links nowhere.
:param parent: parent widget, or ``None``.
"""
super().__init__(str(text), parent)
self._description = str(text)
self._app_key = str(app_key or "")
self._url_override = ""
self._language: Optional[str] = None
self._help_filter = None
if self._app_key:
self.setProperty("moduleApiAppKey", self._app_key)
self._full_description = str(text)
self._refresh_help()
[docs]
def setText(self, text: str) -> None: # noqa: N802 (Qt casing)
"""Remember the whole sentence, then paint as much of it as fits.
:param text: the full description; None becomes ``""``. It is kept
whole and painted elided to fit.
"""
self._full_description = str(text or "")
QLabel.setText(self, self._full_description)
self._elide_to_fit()
[docs]
def full_text(self) -> str:
"""The complete description, however much is being painted."""
return getattr(self, "_full_description", QLabel.text(self))
[docs]
def minimumSizeHint(self) -> QSize: # noqa: N802 (Qt casing)
"""Ask the layout for one word, not for the whole sentence.
THE MASTHEAD ALREADY SAID THIS AND QT WAS NOT LISTENING. It builds
this label with ``setSizePolicy(QSizePolicy.Maximum, ...)`` and
``setMinimumWidth(0)`` and documents the intent -- the blurb "may
shrink below its ideal width rather than force the window wider".
Neither call achieves it: ``qSmartMinSize`` takes a shrinkable
widget's minimum from ``minimumSizeHint()``, which for a
non-wrapping ``QLabel`` is the width of the ENTIRE sentence, and it
only lets an explicit ``minimumWidth`` override that when the value
is greater than zero. So the one control on the masthead that can
lose text harmlessly was the one control that refused to give any
width up.
TWO THINGS FOLLOWED, both measured on the Power screen in Icelandic
at the largest font scale preferences offers:
* THE MODULE TITLE WAS WHAT GOT CUT. The header needed 1274 px and
had 1168, and every one of those 106 px came off the title --
'Tölfræðilegt afl / hönnun' painted in 577 px of a 683 px hint --
while the blurb sat at its full width beside it. A module name
cut mid-word, next to a sentence that had room to spare and a
hover copy of itself.
* AND IT SET THE FLOOR UNDER EVERY MODULE SCREEN. The masthead's
minimum was 1166 px at 100 %, which is most of the ~1198 px
minimum the whole screen reported -- so this label, alone, was
why a module page could not be shown narrow.
ONE WORD PLUS THE ELLIPSIS, rather than a constant: it is measured
in the label's own font, so it tracks the font scale without being
told about it, and at its narrowest the blurb still says what it is
about instead of collapsing to a dot. Never wider than the sentence
itself, so a short description keeps behaving exactly as it did.
Only when the label does not wrap. A wrapping label trades width for
HEIGHT, and Qt's own minimum already knows how.
"""
base = super().minimumSizeHint()
if self.wordWrap():
return base
full = " ".join(self.full_text().split())
if not full:
return base
margins = self.contentsMargins()
metrics = QFontMetrics(self.font())
floor = (metrics.horizontalAdvance(full.split()[0] + "\u2026")
+ margins.left() + margins.right())
return QSize(max(1, min(base.width(), floor)), base.height())
[docs]
def resizeEvent(self, event): # noqa: N802 (Qt naming)
"""Re-elide for the width just granted.
:param event: the resize event, passed to the base class; the text is
then elided to the label's new width.
"""
super().resizeEvent(event)
self._elide_to_fit()
def _elide_to_fit(self) -> None:
"""Paint the full text when it fits, an elided copy when it does not.
ONLY WHEN THE LABEL DOES NOT WRAP. A wrapping label uses its HEIGHT
for the overflow, and eliding one would throw away a line it had room
to draw.
"""
full = getattr(self, "_full_description", "")
if not full or self.wordWrap():
return
margins = self.contentsMargins()
room = self.width() - margins.left() - margins.right()
if room <= 0:
return
metrics = QFontMetrics(self.font())
shown = (full if metrics.horizontalAdvance(full) <= room
else metrics.elidedText(full, Qt.ElideRight, room))
if shown != QLabel.text(self):
QLabel.setText(self, shown)
[docs]
def set_api_app_key(self, app_key: str) -> None:
"""Set the module whose API documentation is linked.
:param app_key: the module whose API page is linked; None or empty
means no link. Any :meth:`set_url` override is dropped.
"""
self._app_key = str(app_key or "")
self._url_override = ""
self.setProperty("moduleApiAppKey", self._app_key or None)
self._refresh_help()
[docs]
def url(self) -> str:
"""Return the documentation URL in the current hover content."""
from .hover_tooltip import split_api_link
return split_api_link(self.help_html())[1]
[docs]
def set_url(self, url: str) -> None:
"""Override the documentation URL while preserving the description.
:param url: the documentation URL to link instead of the module's API
page; empty removes the override.
"""
self._url_override = str(url or "")
self._refresh_help()
[docs]
def help_html(self) -> str:
"""Return the rich-text help used for hover and accessibility."""
return str(self.property("apiTooltipHtml") or "")
[docs]
def retranslate_dynamic_content(self, language: object) -> None:
"""Rebuild translated hover content and its documentation link.
:param language: the language to translate the hover help into,
converted to a string; a falsy value clears it. Any :meth:`set_url`
override is dropped.
"""
self._language = str(language) if language else None
self._url_override = ""
self._refresh_help()
def _compose_help(self) -> str:
"""Build the hover help, with the API link when there is one to make.
With no module key the description is returned as plain escaped text:
the shared formatter would fall back to the documentation index, which
is a link that answers no question the reader asked.
:returns: the help as HTML.
"""
description = self._description.strip()
if not self._app_key:
return escape(description)
from ..screens.settings_model import format_tooltip
from .hover_tooltip import split_api_link
html = format_tooltip(description, self._app_key, "", self._language)
if not self._url_override:
return html
body, current = split_api_link(html)
if current == self._url_override:
return html
from ..i18n import tr
caption = escape(tr("Open spaCR API documentation", self._language))
href = escape(self._url_override, quote=True)
link = f'<a href="{href}">{caption}</a>'
return f"{body}<br>{link}" if body else link
def _refresh_help(self) -> None:
"""Recompose the help and re-arm the hover popup.
The text is kept on the widget as well as in the popup, because that is
what the accessibility tree reads out. Hover help preserves the
operating system cursor instead of assigning a special help shape.
"""
html = self._compose_help()
self.setProperty("apiTooltipHtml", html)
self.setToolTip(html)
self.setToolTipDuration(-1)
self.unsetCursor()
self._install_help_filter()
def _install_help_filter(self) -> None:
"""Show the clickable sticky popup on hover, once.
Qt keeps a LIST of event filters and calls each installation
separately, so a second filter object would pop two tooltips for one
hover. One is built per label and reinstalled idempotently.
"""
if self._help_filter is None:
from ..screens.settings_model import _ApiTooltipFilter
self._help_filter = _ApiTooltipFilter(self)
self.removeEventFilter(self._help_filter)
self.installEventFilter(self._help_filter)