Source code for spacr.qt.widgets.hover_tooltip
"""
HoverTooltip — a QFrame-based popup that stays visible when the mouse
enters it. Unlike QToolTip, users can move their cursor into the popup
to click links inside.
Usage::
tip = HoverTooltip.instance()
tip.show_for(some_widget, "some html") # on hover-enter
tip.start_hide() # on hover-leave
The popup cancels its own hide timer if the mouse enters it, and only
actually hides when neither the anchor nor the popup itself is under
the cursor.
A hover shows **text only**. No GIF is decoded, no frames are cached and no
timer runs until the reader asks for the animation: 141 settings have one, and
each is a ~73 ms decoded movie. A hover that only wanted the sentence should
not pay for one. Measured, a sweep of all 141: 0 decodes, 2.6 ms a hover.
Asked for, the animation appears to the RIGHT of the text. Both columns start
at the same top edge, so the first line of prose and the first frame are read
together rather than one being hunted for beside the other::
+-----------------------------+-----------------------------+
| Cell diameter (int) | |
| Expected cell diameter in | (animation) |
| pixels... | |
| API Animation | |
+-----------------------------+-----------------------------+
The text column is exactly as tall as the animation square and no taller:
its width is widened, one step at a time, until the prose fits inside the
square's height. With no animation beside it the popup shrinks to the
text — nothing is padded out to a shape it does not need.
The last line is two words, not a sentence: **API** in the theme accent opens
the same documentation page the old ``Open spaCR API documentation`` link did,
and **Animation** in teal reveals the square — or folds it away again.
That reveal is PER SETTING. Pressing **Animation** on ``cell_diameter`` shows
``cell_diameter``'s animation and nothing else; move to the next setting and it
is hidden again until its own **Animation** is pressed. A session-wide reveal
was the obvious alternative and is exactly wrong for the machines this was
asked for: one click would put every later hover back on the ~73 ms decode
path for the rest of the run, which is the cost the change exists to avoid.
Measured, the same sweep of 141 settings taken straight after a press: still
0 decodes.
Re-hovering the SAME setting keeps its reveal, so moving the pointer between a
label and the popup below it does not fight the reader. The state is one key
and one bool — see :meth:`HoverTooltip.animations_shown`.
Nothing is decoded before a press. The **Animation** word is offered from a
registry lookup, which reads no pixels; the GIF is read, measured, cropped,
zoomed and rounded only when the word is pressed. Two caches sit under that,
and neither is ever filled speculatively:
* :func:`spacr.qt.widgets.animation_zoom.zoomed_animation` already keeps the
eight most recent zooms (~2.6 MB each), which is what turns a repeat press
from ~73 ms into ~2.9 ms;
* this widget keeps the finished pixmaps of ONE animation — the one last
revealed, ~3.5 MB — so folding a setting away and back is free. They are
dropped the moment the pointer moves to a different setting.
The *Setting animations* preference is the escape hatch for a reader who wants
them always: on, every tooltip starts revealed and the word folds THIS one
away; off — the default — every tooltip starts hidden and the word reveals
THIS one. Because a press only ever names one setting, it can never leave the
preference unable to take effect.
Which animation is decided by the anchor's ``settingKey`` property, so no
caller has to pass one; the callers that put help on a label already set it.
Anything without that property — a section header, a home tile — gets a
text-only popup with no **Animation** word to click.
The popup is ONE surface. Its two layout containers paint nothing, so the
rounded grey frame is the only fill and the page-opacity preference moves it
as a single layer — see :meth:`HoverTooltip._apply_theme` for the black slab
that taught us.
Only one tooltip ever appears. The screens that anchor this popup also leave
a native Qt tooltip on the same label (``refresh_api_tooltips`` re-applies it
on every ``Enter``, for the accessibility tree), and Qt's own tooltip timer
would pop that up a second later, on top of this one — two tooltips, one
after the other. Claiming an anchor therefore installs
:class:`_NativeTooltipSuppressor` on it, which swallows ``QEvent.ToolTip``
while leaving ``toolTip()`` intact for screen readers.
"""
from __future__ import annotations
import logging
import re
from html import unescape
from typing import Optional, Tuple
from PySide6.QtCore import (QEvent, QObject, QPoint, QRectF, QTimer, QUrl, Qt,
Signal)
from PySide6.QtGui import (QDesktopServices, QGuiApplication, QPainter,
QPainterPath, QPixmap)
from PySide6.QtWidgets import (QFrame, QHBoxLayout, QLabel, QToolTip,
QVBoxLayout, QWidget)
from ..theme import SPACING, active_palette, font_px
LOGGER = logging.getLogger(__name__)
#: Sentinel for "work the animation out from the anchor". ``None`` cannot do
#: that job: it is the perfectly good answer "this tooltip has no animation",
#: which callers need to be able to say.
_DERIVE = object()
#: Qt's own "no maximum", which PySide6 does not export. Needed to undo a
#: `setFixedWidth`/`setFixedHeight`, both of which pin the minimum as well.
_UNBOUNDED = 16777215
#: The teal half of the footer. The palette has no teal — `info` is a second
#: name for the blue accent — so this is the DNA-rain default, which is the
#: only teal spaCR already ships as a named constant.
TEAL = "#009B9B"
#: The purple half. Same reasoning as TEAL: the palette has no purple, and
#: `accent` is the blue. Chosen to clear WCAG AA large-text contrast on both
#: the light and dark popup surfaces, which a lighter violet does not.
PURPLE = "#7C3AED"
#: THE TWO WORDS: an API link and an Animation link, with the dot removed, and again for the bottom strip, "which should
#: also just say API".
#:
#: THIS REVERSES A REQUEST MADE EARLIER THE SAME DAY -- "instead of API just
#: show a teel dot for api and a purple square for annimation" -- so the
#: marks are recorded here rather than deleted silently, in case the reasons
#: matter again:
#:
#: * the words were repeated on every row that had them, so they cost a
#: line and carried no information after the first reading;
#: * the marks differed in SHAPE as well as colour, because a colourblind mode was added and a teal dot beside a purple square stays
#: distinguishable when the colours do not.
#:
#: THE SECOND REASON IS SATISFIED BY THIS CHANGE RATHER THAN DROPPED BY IT.
#: "API" and "Animation" are distinguishable with no colour at all, which is
#: strictly better on that axis than two shapes. If the marks ever come back,
#: they must differ in form and not only in hue.
API_MARK = "API"
ANIMATION_MARK = "Animation"
_ANCHOR_RE = re.compile(
r"<a\b[^>]*?href\s*=\s*([\"'])(.*?)\1[^>]*>(.*?)</a>",
re.IGNORECASE | re.DOTALL,
)
_TRAILING_BREAKS_RE = re.compile(r"(?:<br\s*/?>|\s)+$", re.IGNORECASE)
[docs]
def split_api_link(html: str) -> Tuple[str, str]:
"""Split a trailing documentation link off a tooltip body.
``settings_model.format_tooltip`` ends every setting's help with
``<a href="...">Open spaCR API documentation</a>``. The popup renders
that destination as its own **API** word instead, so the anchor is taken
out of the prose here rather than in the formatter — the same string is
still used verbatim by the hint strip, the accessibility tree and every
other consumer of ``format_tooltip``.
Only a link that really is the last thing in the body is taken; a link
inside a sentence stays where the author put it.
:param html: the tooltip body as rich text; the last ``<a href>`` anchor
is removed only when nothing but whitespace follows it, along with
the line breaks before it.
:returns: ``(body_without_the_link, url)``; ``url`` is ``""`` when there
was no trailing link.
"""
last = None
for match in _ANCHOR_RE.finditer(html):
last = match
if last is None or html[last.end():].strip():
return html, ""
body = _TRAILING_BREAKS_RE.sub("", html[:last.start()])
return body, unescape(last.group(2))
def _anchor_setting_key(anchor: Optional[QWidget]) -> str:
"""The setting an anchor speaks for, or ``""``.
Both the reveal and the animation lookup are keyed on this, so it is one
function: two readings of the same property could drift apart and leave a
press scoped to a setting other than the one on screen.
"""
if anchor is None:
return ""
try:
key = anchor.property("settingKey")
except RuntimeError:
return ""
return str(key) if key else ""
class _NativeTooltipSuppressor(QObject):
"""Swallow ``QEvent.ToolTip`` on every widget the popup speaks for.
The settings screens keep a native Qt tooltip on each setting label —
``refresh_api_tooltips`` re-applies it on every ``Enter``, with
``setToolTipDuration(-1)`` so it never times out — because that string is
what the accessibility tree reads out. Qt's tooltip timer then shows it
roughly 700 ms after the pointer settles, which is *after*
:meth:`HoverTooltip.show_for` has already put the sticky popup on screen:
two tooltips, one after the other, the second covering the first.
Deleting the label's ``toolTip()`` would fix the picture and cost the
screen reader its text. Eating the event keeps both.
"""
def eventFilter(self, watched, event): # noqa: N802 (Qt naming)
"""Return ``True`` for tooltip requests, so Qt shows nothing."""
if event.type() == QEvent.ToolTip:
return True
return super().eventFilter(watched, event)
class _LinkWord(QLabel):
"""One coloured, underline-free word that behaves like a link.
Not an ``<a>``: Qt's rich text underlines anchors, and the two words were
asked for without one. Colour comes from the popup's own stylesheet (see
:meth:`HoverTooltip._apply_theme`) so both words re-theme together.
:param text: the word, drawn as PLAIN text -- see above for why it is
not an anchor.
:param object_name: the QSS handle this word is styled by. Given per
word rather than fixed, because the popup's sheet colours each one
differently and there is no other way for it to tell them apart.
:param parent: parent widget; ownership only.
"""
clicked = Signal()
def __init__(self, text: str, object_name: str,
parent: Optional[QWidget] = None):
"""Build the word: plain text, styled by its object name."""
super().__init__(text, parent)
self.setObjectName(object_name)
self.setTextFormat(Qt.PlainText)
self.setCursor(Qt.PointingHandCursor)
self.setFocusPolicy(Qt.NoFocus)
def mouseReleaseEvent(self, event): # noqa: N802 (Qt naming)
"""Emit :attr:`clicked` for a left button released over the word."""
if (event.button() == Qt.LeftButton
and self.rect().contains(event.position().toPoint())):
self.clicked.emit()
event.accept()
return
super().mouseReleaseEvent(event)
class _AnimationView(QLabel):
"""Plays one pre-zoomed setting animation at a fixed square size.
Not a ``QMovie``: the frames are cropped and rescaled from the packaged
GIF before they are shown (see
:mod:`spacr.qt.widgets.animation_zoom`), which ``QMovie`` cannot do. It
plays finished frames on a timer instead, honouring each frame's own
delay so the generated timing survives.
The corners are rounded into the frames themselves. A stylesheet
``border-radius`` rounds only the background the label paints *under* the
pixmap, and the pixmap is opaque to its own edges — so the square stayed
square however the sheet was written.
:param size: the square's side in px. FIXED at construction, both as the
widget's size and as the size frames are scaled to, so a later
layout change cannot stretch the square into a rectangle.
:param parent: parent widget; ownership only.
"""
#: Corner radius of the square, in pixels.
CORNER_RADIUS = 10
def __init__(self, size: int, parent: Optional[QWidget] = None):
"""Build the square, fixed at ``size`` with no frames yet."""
super().__init__(parent)
self.setObjectName("SettingTooltipAnimation")
self._size = int(size)
self.setFixedSize(self._size, self._size)
self.setAlignment(Qt.AlignCenter)
self._frames: list = []
self._delays: list = []
self._index = 0
self._slug = ""
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._advance)
def slug(self) -> str:
"""Slug of the animation currently loaded, or ``""``."""
return self._slug
def rounded(self, pixmap: QPixmap) -> QPixmap:
"""Return ``pixmap`` clipped to this view's rounded rectangle.
The black backing is painted inside the same path, not left to the
stylesheet: a square background behind a rounded pixmap would
fill the corners back in.
"""
radius = float(self.CORNER_RADIUS)
out = QPixmap(pixmap.size())
out.fill(Qt.transparent)
painter = QPainter(out)
try:
painter.setRenderHint(QPainter.Antialiasing, True)
path = QPainterPath()
path.addRoundedRect(QRectF(pixmap.rect()), radius, radius)
painter.setClipPath(path)
painter.fillPath(path, Qt.black)
painter.drawPixmap(0, 0, pixmap)
finally:
painter.end()
return out
def load(self, animation) -> bool:
"""Load and start ``animation``; return whether anything is showing.
A failure to decode is not an error worth interrupting a hover for —
it returns ``False`` and the tooltip falls back to text only.
"""
from .animation_zoom import to_qimage, zoomed_animation
if animation is None:
self.clear_animation()
return False
if self._slug == animation.slug and self._frames:
self.play()
return True
zoomed = zoomed_animation(str(animation.path), self._size)
if zoomed is None or not zoomed.frames:
LOGGER.warning(
"Could not load setting animation %s from %s",
animation.slug, animation.path,
)
self.clear_animation()
return False
self._frames = [
self.rounded(QPixmap.fromImage(to_qimage(frame)))
for frame in zoomed.frames
]
self._delays = list(zoomed.delays)
self._slug = animation.slug
self._index = 0
self.setPixmap(self._frames[0])
self.setAccessibleName(animation.title)
self.setAccessibleDescription(
"Animated explanation of this spaCR setting."
)
self._schedule()
return True
def clear_animation(self) -> None:
"""Stop playing and drop the frames."""
self._timer.stop()
self._frames = []
self._delays = []
self._index = 0
self._slug = ""
self.setPixmap(QPixmap())
def stop(self) -> None:
"""Pause playback without forgetting the loaded animation."""
self._timer.stop()
def play(self) -> None:
"""Resume playback of the frames already loaded."""
if self._frames and not self._timer.isActive():
self._schedule()
def is_playing(self) -> bool:
"""Whether the frame timer is currently running."""
return self._timer.isActive()
def frame_count(self) -> int:
"""How many frames are loaded."""
return len(self._frames)
#: Floor on a frame delay. The packaged animations run at 80 ms, but a
#: hand-made GIF claiming 10 ms would spin this timer against the paint
#: loop for no visible gain.
MIN_DELAY_MS = 20
def _schedule(self) -> None:
"""Arm the timer for the current frame's own delay.
Per-frame rather than one interval for all of them, because a GIF's
frames are not evenly spaced. A single still has nothing to schedule and
is left alone rather than ticking a timer for one image.
"""
if len(self._frames) < 2:
return
self._timer.start(
max(self.MIN_DELAY_MS, int(self._delays[self._index])))
def _advance(self) -> None:
"""Show the next frame and re-arm. Wraps at the end."""
if not self._frames:
return
self._index = (self._index + 1) % len(self._frames)
self.setPixmap(self._frames[self._index])
self._schedule()
[docs]
class HoverTooltip(QFrame):
"""Sticky QFrame popup that survives cursor entry so users can click links.
Access via :meth:`instance` — the popup is a process-wide singleton.
"""
_INSTANCE: Optional["HoverTooltip"] = None
#: Side of the square animation box.
ANIMATION_SIZE = 220
#: Width the text uses when there is no animation to sit beside.
TEXT_WIDTH = 380
#: Widths tried, in order, for the text column when an animation IS
#: beside it. The first one whose prose fits inside the square's height
#: wins, so short help keeps the neat pair of equal columns and long help
#: spreads sideways instead of growing a tall ribbon. Measured against
#: every packaged animation's help text: 380 is enough for all of them.
TEXT_WIDTH_STEPS = (ANIMATION_SIZE, 260, 300, 340, TEXT_WIDTH)
def __init__(self):
"""Build the process-wide hover popup.
A tool-tip window with our own painting: shown without activating, so it
never takes focus from what the pointer is over.
"""
super().__init__(
None,
Qt.ToolTip | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint,
)
self.setObjectName("HoverTooltip")
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
self._apply_theme()
self._text_column = QWidget(self)
self._text_column.setObjectName("HoverTooltipTextColumn")
column = QVBoxLayout(self._text_column)
column.setContentsMargins(0, 0, 0, 0)
column.setSpacing(SPACING["xs"])
self._label = QLabel(self._text_column)
self._label.setObjectName("HoverTooltipText")
self._label.setTextFormat(Qt.RichText)
self._label.setOpenExternalLinks(True)
self._label.setTextInteractionFlags(
Qt.TextBrowserInteraction | Qt.LinksAccessibleByMouse
)
self._label.setWordWrap(True)
self._label.setMaximumWidth(self.TEXT_WIDTH)
self._label.setAlignment(Qt.AlignJustify | Qt.AlignTop)
column.addWidget(self._label)
self._links = QWidget(self._text_column)
self._links.setObjectName("HoverTooltipLinks")
links_row = QHBoxLayout(self._links)
links_row.setContentsMargins(0, 0, 0, 0)
links_row.setSpacing(SPACING["sm"])
self._api_link = _LinkWord(API_MARK, "HoverTooltipApiLink",
self._links)
self._api_link.setAccessibleName("API")
self._api_link.setAccessibleDescription(
"Open spaCR API documentation for this setting."
)
self._api_link.clicked.connect(self.open_api_documentation)
self._animation_link = _LinkWord(
ANIMATION_MARK, "HoverTooltipAnimationLink", self._links)
self._animation_link.setAccessibleName("Animation")
self._animation_link.setAccessibleDescription(
"Show or hide this setting's animation."
)
self._animation_link.clicked.connect(self.toggle_animation)
links_row.addWidget(self._api_link)
links_row.addWidget(self._animation_link)
links_row.addStretch(1)
column.addWidget(self._links)
column.addStretch(1)
self._animation_view = _AnimationView(self.ANIMATION_SIZE, self)
self._animation_view.hide()
self._animation = None
self._offered_animation = None
self._setting_key = ""
self._toggled_key: Optional[str] = None
self._toggled_to = False
self._api_url = ""
lay = QHBoxLayout(self)
lay.setContentsMargins(SPACING["sm"], SPACING["xs"],
SPACING["sm"], SPACING["xs"])
lay.setSpacing(SPACING["sm"])
lay.addWidget(self._text_column, 0, Qt.AlignTop)
lay.addWidget(self._animation_view, 0, Qt.AlignTop)
from ..tooltip_policy import HoverDelay
self._hover_delay = HoverDelay(self)
self._hover_delay.invalidated.connect(self.hide)
self._hide_timer = QTimer(self)
self._hide_timer.setSingleShot(True)
self._hide_timer.timeout.connect(self._maybe_hide)
self._anchor: Optional[QWidget] = None
self._tooltip_suppressor = _NativeTooltipSuppressor(self)
def _apply_theme(self) -> None:
"""Refresh the popup's inline style from the theme on screen."""
palette = active_palette()
self.setStyleSheet(
f"QFrame#HoverTooltip {{"
f" background-color: {palette['surface_alt']};"
f" border: 1px solid {palette['border']};"
f" border-radius: 6px;"
f"}}"
f"QWidget#HoverTooltipTextColumn,"
f"QWidget#HoverTooltipLinks {{"
f" background: transparent;"
f"}}"
f"QLabel {{"
f" color: {palette['fg']};"
f" font-size: {font_px('small')}px;"
f" background: transparent;"
f"}}"
f"QLabel#SettingTooltipAnimation {{"
f" background: transparent;"
f"}}"
f"QLabel#HoverTooltipApiLink {{"
f" color: {TEAL};"
f" font-size: {font_px('small') + 4}px;"
f" text-decoration: none;"
f"}}"
f"QLabel#HoverTooltipAnimationLink {{"
f" color: {PURPLE};"
f" font-size: {font_px('small') + 4}px;"
f" text-decoration: none;"
f"}}"
)
@classmethod
[docs]
def instance(cls) -> "HoverTooltip":
"""Return the process-wide singleton, creating it on first access."""
if cls._INSTANCE is None:
cls._INSTANCE = HoverTooltip()
return cls._INSTANCE
[docs]
def show_for(self, anchor: QWidget, html: str, animation=_DERIVE,
*, immediate: bool = False) -> None:
"""Show the tooltip beneath ``anchor`` with body ``html``.
:param anchor: widget the popup docks to (clamped to its screen).
:param html: rich-text body; empty strings are ignored. A trailing
documentation link is moved out of the prose and into the **API**
word at the foot of the popup.
:param animation: a :class:`spacr.setting_animations.SettingAnimation`
to play beside the text, or ``None`` for text only. Left out, it
is derived from the anchor's ``settingKey`` property — every
caller that attaches setting help already sets that, so none of
them had to change.
:param immediate: show now for an explicit click or an already delayed
hover callback; ordinary hover callers leave this false.
"""
if not html:
return
if not immediate:
self.hide()
self._claim_anchor(anchor)
self._hover_delay.schedule(
anchor, lambda: self.show_for(anchor, html, animation,
immediate=True))
return
self._hover_delay.cancel()
self.cancel_hide()
self._apply_theme()
self._anchor = anchor
self._claim_anchor(anchor)
self._setting_key = _anchor_setting_key(anchor)
body, url = split_api_link(str(html))
self._api_url = url
self._api_link.setVisible(bool(url))
self._label.setText(body)
self._set_animation(self._resolve_animation(anchor, animation))
self.adjustSize()
self._position_under(anchor)
self.show()
#: How long the popup waits before hiding, in milliseconds.
#:
#: Long enough for the cursor to cross the gap from a setting label to the
#: popup and trigger its cancel-on-enter behavior.
#:
#: The cost of being generous is a tooltip that lingers a moment after
#: the cursor has genuinely left, which is the mistake worth making: the
#: other one loses text the user was reading.
HIDE_DELAY_MS = 700
[docs]
def start_hide(self, delay_ms: int = 0) -> None:
"""Schedule a hide after ``delay_ms`` unless the cursor re-enters.
``0`` means :data:`HIDE_DELAY_MS` -- the default is named rather than
written into the signature so every caller moves together.
"""
self._hover_delay.cancel()
self._hide_timer.start(int(delay_ms) or self.HIDE_DELAY_MS)
[docs]
def cancel_hide(self) -> None:
"""Cancel any pending hide timer (called on cursor re-entry)."""
self._hide_timer.stop()
[docs]
def animation(self):
"""The animation currently shown beside the text, or ``None``."""
return self._animation
[docs]
def offered_animation(self):
"""The animation this anchor has, shown or collapsed by the toggle."""
return self._offered_animation
[docs]
def animation_view(self) -> _AnimationView:
"""The square animation panel — exposed for layout tests."""
return self._animation_view
[docs]
def text_label(self) -> QLabel:
"""The explanation panel — exposed for layout tests."""
return self._label
[docs]
def text_column(self) -> QWidget:
"""The prose and the two link words, as one block."""
return self._text_column
[docs]
def animation_link(self) -> _LinkWord:
"""The teal **Animation** word that toggles the square."""
return self._animation_link
[docs]
def api_url(self) -> str:
"""Documentation URL taken out of the body, or ``""``."""
return self._api_url
[docs]
def animations_shown(self) -> bool:
"""Whether the setting currently hovered shows its animation.
Off unless this setting was asked for. A press on **Animation** names
one setting; every other setting falls back to the *Setting
animations* preference, which means "show animations without asking"
and defaults, like this, to off.
Scoped to a setting rather than to the session on purpose: a reveal
that outlived the setting would put every later hover back on the
~73 ms decode path after a single press, which is the cost the reader
was trying to avoid. Nothing here needs to guard the preference
either — a press cannot reach past the setting it named.
Read on every hover, never cached: the popup is a process-wide
singleton that outlives the Preferences dialog.
"""
from ..preferences import get_setting_animations_enabled
if (self._toggled_key is not None
and self._toggled_key == self._setting_key):
return self._toggled_to
return get_setting_animations_enabled()
[docs]
def toggled_setting(self) -> Optional[str]:
"""The one setting a press has spoken for, or ``None`` — for tests."""
return self._toggled_key
[docs]
def open_api_documentation(self) -> None:
"""Open the documentation page the body's trailing link pointed at."""
if not self._api_url:
return
QDesktopServices.openUrl(QUrl(self._api_url))
[docs]
def toggle_animation(self) -> None:
"""Reveal this setting's animation, or fold it away again.
Deliberately not written to :mod:`spacr.qt.preferences`, and
deliberately naming one setting. This is the reader asking to see
*this* animation; the preference is the reader asking to stop being
asked about any of them. Because the press names a setting, it cannot
turn animations on for the next one, and it cannot leave the
preference unable to take effect.
"""
wanted = not self.animations_shown()
self._toggled_key = self._setting_key
self._toggled_to = wanted
self._set_animation(self._offered_animation)
self.adjustSize()
if self.isVisible() and self._anchor is not None:
self._position_under(self._anchor)
def _resolve_animation(self, anchor: QWidget, animation):
"""Which animation this anchor HAS, shown or not.
A registry lookup, not a decode — nothing here reads a GIF, so asking
it on every hover costs nothing even when the answer stays folded
away. Whether it is put on screen is :meth:`animations_shown`'s call,
made in :meth:`_set_animation`; this one has to answer regardless,
because a setting with no animation is the one case where there is no
**Animation** word to click.
"""
if animation is not _DERIVE:
return animation
key = (anchor.property("settingAnimationKey") if anchor is not None else None) or _anchor_setting_key(anchor)
if not key:
return None
try:
from spacr.setting_animations import (
SettingAnimationError, animation_for_setting,
)
return animation_for_setting(str(key))
except SettingAnimationError:
LOGGER.exception(
"Setting animation registry is invalid; %s keeps text help "
"only", key,
)
return None
def _set_animation(self, animation) -> None:
"""Show ``animation`` beside the text, or fall back to text only."""
self._offered_animation = animation
revealed = self.animations_shown()
if animation is not None and revealed:
showing = self._animation_view.load(animation)
elif (animation is not None
and self._animation_view.slug() == animation.slug):
self._animation_view.stop()
showing = False
else:
self._animation_view.clear_animation()
showing = False
self._animation = animation if showing else None
self._animation_view.setVisible(showing)
self._animation_link.setVisible(
animation is not None and (showing or not revealed))
self._links.setVisible(
self._api_link.isVisibleTo(self._links)
or self._animation_link.isVisibleTo(self._links))
self._resize_text_column(showing)
def _text_height_at(self, width: int) -> int:
"""Height the prose plus the two words need at ``width`` pixels."""
prose = max(0, self._label.heightForWidth(width))
return prose + SPACING["xs"] + self._links.sizeHint().height()
def _fitting_text_width(self) -> int:
"""Narrowest column width whose text fits inside the square's height."""
for width in self.TEXT_WIDTH_STEPS:
if self._text_height_at(width) <= self.ANIMATION_SIZE:
return width
return self.TEXT_WIDTH
def _unpin_text(self) -> None:
"""Drop every explicit size on the text block.
Not housekeeping — a precondition of measuring it. ``QLabel``'s
``heightForWidth`` ends in ``expandedTo(minimumSize())``, so a label
still pinned by the *previous* hover answers every width with the
previous hover's height, and the width ladder below then reads the
same number at 220 px and at 900 px and never finds a fit.
"""
self._label.setMinimumSize(0, 0)
self._label.setMaximumSize(_UNBOUNDED, _UNBOUNDED)
self._links.setMinimumWidth(0)
self._links.setMaximumWidth(_UNBOUNDED)
self._text_column.setMinimumSize(0, 0)
self._text_column.setMaximumSize(_UNBOUNDED, _UNBOUNDED)
def _resize_text_column(self, with_animation: bool) -> None:
"""Size the text block: square-high beside an animation, tight alone.
Polished first, and that is not a formality. ``_apply_theme`` sets the
prose font through the popup's stylesheet, and an unpolished widget
answers ``heightForWidth`` in the *application* font — 119 px where
the real answer is 250. The first hover of the session therefore
picked the narrowest column in the ladder and then pinned the label to
a height barely half the text, clipping the help. Every later hover
measured correctly, so the fault only ever showed on the first one.
"""
self.ensurePolished()
self._label.ensurePolished()
self._links.ensurePolished()
self._unpin_text()
if not with_animation:
self._label.setMaximumWidth(self.TEXT_WIDTH)
return
width = self._fitting_text_width()
self._label.setFixedWidth(width)
prose = max(0, self._label.heightForWidth(width))
needed = prose + SPACING["xs"] + self._links.sizeHint().height()
self._label.setFixedHeight(prose)
self._links.setFixedWidth(width)
self._text_column.setFixedWidth(width)
self._text_column.setFixedHeight(max(self.ANIMATION_SIZE, needed))
def _position_under(self, anchor: Optional[QWidget]) -> None:
"""Dock the popup just below ``anchor``, clamped to its screen."""
try:
below_left = anchor.mapToGlobal(anchor.rect().bottomLeft())
except (AttributeError, RuntimeError):
below_left = QPoint(0, 0)
screen = QGuiApplication.screenAt(below_left) \
or QGuiApplication.primaryScreen()
if screen is None:
self.move(below_left)
return
geo = screen.availableGeometry()
x = min(max(geo.left(), below_left.x()), geo.right() - self.width())
y = below_left.y() + 4
if y + self.height() > geo.bottom():
try:
top = anchor.mapToGlobal(anchor.rect().topLeft()).y()
except (AttributeError, RuntimeError):
top = below_left.y()
y = top - self.height() - 4
self.move(x, y)
def _claim_anchor(self, anchor: Optional[QWidget]) -> None:
"""Take the anchor's tooltip duty away from Qt's own popup.
Idempotent by construction: Qt keeps a *list* of event filters and
calls each installation separately, so the remove-then-install pair
is what stops a re-hovered label from stacking suppressors. It also
keeps this filter LAST installed and therefore FIRST called, ahead of
the screen's own filter.
"""
if anchor is None:
return
from ..tooltip_policy import OPT_OUT_PROPERTY, tooltip_policy
try:
anchor.removeEventFilter(self._tooltip_suppressor)
anchor.installEventFilter(self._tooltip_suppressor)
anchor.setProperty(OPT_OUT_PROPERTY, True)
except RuntimeError:
return
policy = tooltip_policy()
if policy is not None:
policy.hide_now()
QToolTip.hideText()
def _pointer_is_on_me(self) -> bool:
"""Is the pointer over this popup, asked in a way every platform
answers the same.
`underMouse()` IS NOT RELIABLE HERE and that is the whole reason this
exists. It reports whether Qt has delivered an Enter to the widget,
and a `Qt.ToolTip` window is precisely the kind of window a platform
may decline to send mouse events to -- so on one desktop the popup
knows the pointer is on it and survives, and on another it never
learns and hides while being read.
Reported repeatedly against the picture settings ("the tooltipps ...
dissapear when the the mouse is hovering over the tooltip text") and
NOT REPRODUCIBLE on the development machine, where `underMouse()`
answers True. A behaviour that depends on which window manager is
running is not a behaviour; the geometry test does not.
"""
from PySide6.QtGui import QCursor
try:
return self.frameGeometry().contains(QCursor.pos())
except (RuntimeError, TypeError):
return False
def _maybe_hide(self) -> None:
"""Hide the popup, unless the pointer is still on it or on its anchor.
Checking the anchor for ``None`` is not enough. This popup is a
process-wide singleton holding a plain reference to a widget it does not
own, and the hide is deferred by a timer -- so hovering a settings label
and switching module inside the delay destroys the anchor's C++ object
while the timer is still pending. The Python wrapper survives, the
``None`` check passes, and the geometry query then raises inside the Qt
event loop, where there is nobody to catch it.
"""
if self._pointer_is_on_me():
return
anchor = self._anchor
if anchor is not None:
try:
from PySide6.QtGui import QCursor
if anchor.rect().contains(
anchor.mapFromGlobal(QCursor.pos())):
return
except RuntimeError:
self._anchor = None
self.hide()
[docs]
def hideEvent(self, event):
"""Stop decoding frames the moment the popup leaves the screen.
The popup is a singleton, so without this its timer would keep
swapping pixmaps into an invisible label for the rest of the session
after the last hover.
:param event: the hide event; passed to the base class after the
animation stops.
"""
self._hover_delay.cancel()
self._animation_view.stop()
super().hideEvent(event)
[docs]
def showEvent(self, event):
"""Resume the loaded animation when the popup comes back.
:param event: the show event; passed to the base class and otherwise
not read.
"""
super().showEvent(event)
if self._animation is not None:
self._animation_view.play()
[docs]
def enterEvent(self, event):
"""Cancel the hide timer when the cursor enters the popup.
:param event: the enter event; passed to the base class and otherwise
not read.
"""
self.cancel_hide()
super().enterEvent(event)
[docs]
def leaveEvent(self, event):
"""Restart the hide timer when the cursor leaves the POPUP itself.
Shorter than the anchor's grace period on purpose: leaving the popup
is a deliberate act, where leaving the label may just be the journey
towards it.
:param event: the leave event; passed to the base class and otherwise
not read.
"""
self.start_hide(delay_ms=250)
super().leaveEvent(event)
__all__ = ["HoverTooltip", "TEAL", "split_api_link"]