"""One rule for every tooltip in spaCR: when it appears and when it goes.
Qt's own answer is a style hint, ``SH_ToolTip_WakeUpDelay``, which most
styles set to about 700 ms and which nothing in spaCR was choosing. Tooltips
therefore arrived while the pointer was still travelling, and left the
instant it moved off the widget -- too fast to read, and impossible to reach
if the text ran long.
This module installs ONE event filter on ``QApplication`` and takes the
decision away from the style:
* a tooltip appears after the Tooltip delay preference of hovering, not
before (:data:`SHOW_DELAY_MS` when nothing is stored);
* it stays while the pointer is on the widget OR on the tooltip itself;
* it leaves :data:`LINGER_MS` after the pointer leaves both.
Because the filter sits on the application object, a widget written later
obeys the rule without anyone remembering to ask for it: its ``ToolTip``
event travels to the application like every other one.
The filter shows the text itself, with ``QToolTip.showText`` and no owning
widget, rather than letting Qt show it. Handing Qt the widget hands Qt the
hiding as well -- Qt hides on the widget's ``Leave`` event, immediately,
which is the one behaviour this module exists to change.
Two things follow from taking the event, and both are handled rather than
accepted:
* Qt PROPAGATES a tooltip event to the parent widget when the widget under
the pointer has no tooltip of its own, which is how a card explains
itself while the pointer is on the label written on it. The text is
therefore resolved with :func:`tooltip_text_for`, up the parent chain,
exactly as Qt would have.
* A table, a header or a list answers from ``Qt::ToolTipRole`` inside its
own ``event``, not from a ``toolTip()`` any filter can read. When
nothing in the parent chain has a tooltip, the event is SENT AGAIN after
the wait, with the filter standing aside, so those still appear -- and
appear on the same two-second rule as everything else.
:func:`tooltips_enabled` is the preference switch, on by default. Cleared,
the ``ToolTip`` event is swallowed and no tooltip is shown anywhere.
"""
from __future__ import annotations
import logging
import weakref
from typing import Optional
from PySide6.QtCore import QEvent, QObject, QPoint, QTimer, Qt, Signal, Slot
from PySide6.QtGui import QCursor
from PySide6.QtWidgets import QApplication, QToolTip
from .gil_priority import (_stop_watching_application_events,
_watch_application_events)
_TOOLTIP_MOMENTS = frozenset({
QEvent.Type.ToolTip, QEvent.Type.Enter, QEvent.Type.Leave,
QEvent.Type.Hide, QEvent.Type.Close, QEvent.Type.DeferredDelete,
QEvent.Type.WindowDeactivate, QEvent.Type.MouseButtonPress,
QEvent.Type.Wheel, QEvent.Type.KeyPress, QEvent.Type.MouseMove,
})
LOG = logging.getLogger(__name__)
#: How long the pointer must rest before a tooltip appears, in milliseconds.
#: The maintainer asked for two seconds (2026-09-24): long enough that
#: crossing a toolbar never raises one, short enough to be an answer to a
#: question rather than a wait.
SHOW_DELAY_MS = 2000
#: How long a shown tooltip stays after the pointer has left both the widget
#: and the tooltip, in milliseconds.
LINGER_MS = 1000
#: How long ``QToolTip`` is told to keep the text up. This module decides
#: when the tooltip goes, so Qt's own expiry must not get there first; a
#: tooltip the pointer never leaves stays for an hour and then gives up,
#: which is the same as forever and cannot leak a stuck window.
HOLD_MS = 3600 * 1000
#: Dynamic property that bypasses the application-wide native tooltip policy.
#: Custom hover popups set it alongside their widget-level event suppressor;
#: other widgets can use it to retain Qt's own tooltip handling.
OPT_OUT_PROPERTY = "spacrNoTooltipPolicy"
_filter: Optional["_TooltipFilter"] = None
_enabled: Optional[bool] = None
_delay_ms: Optional[int] = None
_hover_delays = weakref.WeakSet()
def _preferred_delay_ms() -> int:
"""The Tooltip delay preference in milliseconds. Cached.
Read on every hover, so the answer is kept until
:func:`invalidate_tooltip_policy` drops it, which saving the
preference does.
"""
global _delay_ms
if _delay_ms is None:
try:
from .preferences import _get_tooltip_delay
_delay_ms = int(round(float(_get_tooltip_delay()) * 1000))
except Exception: # noqa: BLE001
LOG.debug("could not read the tooltip delay", exc_info=True)
_delay_ms = SHOW_DELAY_MS
return _delay_ms
def _style_wake_up_ms() -> int:
"""How long Qt waits before it sends a ``ToolTip`` event at all."""
try:
from PySide6.QtWidgets import QStyle
style = QApplication.style()
if style is not None:
return max(0, int(style.styleHint(
QStyle.StyleHint.SH_ToolTip_WakeUpDelay)))
except Exception: # noqa: BLE001
return 0
return 0
[docs]
class HoverDelay(QObject):
"""Schedule custom hover help using the global delay in seconds.
:param parent: QObject owning this hover surface and its timer.
"""
invalidated = Signal()
def __init__(self, parent=None):
"""Create an idle timer owned by ``parent``.
:param parent: QObject whose lifetime owns this timer.
"""
super().__init__(parent)
self._anchor = None
self._window = None
self._callback = None
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.setTimerType(Qt.PreciseTimer)
self._timer.timeout.connect(self._deliver)
_hover_delays.add(self)
[docs]
def schedule(self, anchor, callback) -> None:
"""Wait a full continuous hover before invoking ``callback``.
:param anchor: widget being hovered; leaving or hiding cancels.
:param callback: zero-argument callable that presents the help.
"""
self.cancel()
if not tooltips_enabled():
return
self._anchor = anchor
self._window = anchor.window()
self._callback = callback
anchor.installEventFilter(self)
anchor.destroyed.connect(self.cancel)
if self._window is not anchor:
self._window.installEventFilter(self)
self._timer.start(_preferred_delay_ms())
@Slot()
[docs]
def cancel(self) -> None:
"""Cancel pending help and release its target and callback."""
from shiboken6 import isValid
timer = getattr(self, "_timer", None)
if timer is None or not isValid(timer):
return
timer.stop()
anchor = getattr(self, "_anchor", None)
window = getattr(self, "_window", None)
for target in (anchor, window):
if target is not None:
try:
target.removeEventFilter(self)
except RuntimeError:
pass
if anchor is not None:
try:
anchor.destroyed.disconnect(self.cancel)
except (RuntimeError, TypeError):
pass
self._anchor = self._window = self._callback = None
[docs]
def cancel_for(self, anchor) -> None:
"""Cancel only the target that left, allowing late neighbouring leaves.
:param anchor: widget sending the leave event.
"""
if anchor is getattr(self, "_anchor", None):
self.cancel()
[docs]
def eventFilter(self, obj, event): # noqa: N802
"""Cancel when the target leaves or its window is hidden/closed.
:param obj: anchor or its top-level window.
:param event: event observed without consuming it.
"""
kind = event.type()
if (kind in (QEvent.Hide, QEvent.Close, QEvent.DeferredDelete)
or (obj is getattr(self, "_anchor", None) and kind == QEvent.Leave)):
self.cancel()
return False
def _deliver(self):
"""Present help only while its target still exists and is visible."""
anchor, callback = self._anchor, self._callback
self.cancel()
if anchor is None or callback is None or not tooltips_enabled():
return
try:
visible = anchor.isVisible()
except RuntimeError:
return
if visible:
callback()
[docs]
def tooltip_text_for(widget) -> str:
"""The tooltip a hover on ``widget`` would raise, parents included.
This is not a convenience: it is the behaviour being preserved. Qt
PROPAGATES a tooltip event up the parent chain, so a label with no
tooltip of its own inside a card that has one shows the card's. A
filter that read ``widget.toolTip()`` alone and then swallowed the
event would break every one of those -- a card explains itself until
the pointer lands on the text written on it, and then it stops.
:param widget: the hovered widget, or ``None``; its ``toolTip()`` is read,
then each parent's up to and including its window (at most 64
levels).
:returns: the first non-empty tooltip from the widget outwards, or
``""`` if neither it nor any parent up to its window has one.
"""
seen = 0
while widget is not None and seen < 64:
seen += 1
try:
text = str(widget.toolTip() or "")
except Exception: # noqa: BLE001
return ""
if text:
return text
try:
if widget.isWindow():
return ""
widget = widget.parentWidget()
except Exception: # noqa: BLE001
return ""
return ""
class _TooltipFilter(QObject):
"""The application-wide filter. One instance, installed once."""
def __init__(self, show_delay_ms: Optional[int] = None,
linger_ms: int = LINGER_MS) -> None:
"""Set the two timings; the filter is idle until installed.
:param show_delay_ms: how long the pointer rests before the tip
shows; ``None`` follows the Tooltip delay preference.
:param linger_ms: how long a shown tip stays after the pointer leaves.
"""
super().__init__()
self._fixed_delay_ms = (None if show_delay_ms is None
else int(show_delay_ms))
self.linger_ms = int(linger_ms)
self._widget: Optional[object] = None
self._watched_widget = None
self._text = ""
self._pos = QPoint()
self._showing = False
self._replaying = False
self._show_timer = QTimer(self)
self._show_timer.setSingleShot(True)
self._show_timer.setTimerType(Qt.PreciseTimer)
self._show_timer.timeout.connect(self._show_now)
self._hide_timer = QTimer(self)
self._hide_timer.setSingleShot(True)
self._hide_timer.timeout.connect(self._hide_if_the_pointer_left)
@property
def show_delay_ms(self) -> int:
"""How long the pointer rests before the tip shows, in ms."""
if self._fixed_delay_ms is not None:
return self._fixed_delay_ms
return _preferred_delay_ms()
@show_delay_ms.setter
def show_delay_ms(self, value) -> None:
"""Fix the delay; ``None`` follows the preference again."""
self._fixed_delay_ms = None if value is None else int(value)
def eventFilter(self, obj, event) -> bool: # noqa: N802
"""Take over tooltip events; leaving, clicks and keys hide the tip.
:param obj: the object the event was sent to.
:param event: the event; only ``ToolTip`` is ever consumed.
:returns: ``True`` when the event was handled here.
A view owns many cell targets beneath one viewport widget, so a help
event is not replayed for the cell the pointer left.
"""
try:
kind = event.type()
except Exception: # noqa: BLE001
return False
if kind == QEvent.Type.ToolTip:
return self._on_tooltip(obj, event)
if (kind == QEvent.Type.MouseMove and obj is self._widget
and not self._text and self._show_timer.isActive()):
if event.globalPosition().toPoint() != self._pos:
self.hide_now()
return False
if kind == QEvent.Type.Enter:
self._on_enter(obj)
return False
if kind == QEvent.Type.Leave:
if obj is self._widget:
self._start_the_linger()
elif kind in (QEvent.Type.Hide, QEvent.Type.Close,
QEvent.Type.DeferredDelete, QEvent.Type.WindowDeactivate):
try:
belongs = (obj is self._widget or
(kind != QEvent.Type.WindowDeactivate and
self._widget is not None and
obj is self._widget.window()))
except RuntimeError:
belongs = True
if belongs:
self.hide_now()
elif kind in (QEvent.Type.MouseButtonPress,
QEvent.Type.Wheel,
QEvent.Type.KeyPress):
self.hide_now()
return False
def _track_widget(self, widget) -> None:
"""Cancel immediately if the current native tooltip target dies.
:param widget: current target, or None when no target remains.
"""
previous = self._watched_widget
self._widget = widget
if previous is widget:
return
self._watched_widget = None
if previous is not None:
try:
previous.destroyed.disconnect(self.hide_now)
except (AttributeError, RuntimeError, TypeError):
pass
if widget is not None:
try:
widget.destroyed.connect(self.hide_now)
self._watched_widget = widget
except (AttributeError, RuntimeError):
pass
def _on_enter(self, obj) -> None:
"""Start a full wait on entry, independently of Qt's style delay.
Qt may deliver its tooltip request early after another tooltip was
visible. Never assume the style's nominal wake-up time has elapsed.
View-owned help is scheduled when its ToolTip event arrives.
:param obj: the object the pointer entered.
"""
delay = self.show_delay_ms
if not tooltips_enabled():
return
if not hasattr(obj, "toolTip"):
return
try:
if bool(obj.property(OPT_OUT_PROPERTY)):
return
except Exception: # noqa: BLE001
return
text = tooltip_text_for(obj)
if not text:
return
self._hide_timer.stop()
if obj is self._widget and text == self._text and (self._showing
or self._show_timer.isActive()):
self._track_widget(obj)
return
if self._showing:
self._hide_text()
self._show_timer.stop()
self._track_widget(obj)
self._text = text
self._pos = QCursor.pos()
self._show_timer.start(delay)
def _on_tooltip(self, obj, event) -> bool:
"""Start (or keep) the show timer for the hovered widget's tip.
:param obj: the object the ``ToolTip`` event was sent to.
:param event: the ``ToolTip`` event; its global position is kept.
:returns: ``True`` when the event is consumed.
"""
if self._replaying:
return False
if not tooltips_enabled():
self.hide_now()
return True
widget = obj if hasattr(obj, "toolTip") else None
if widget is None:
return False
try:
if bool(widget.property(OPT_OUT_PROPERTY)):
return False
except Exception: # noqa: BLE001
pass
try:
self._pos = QPoint(event.globalPos())
except Exception: # noqa: BLE001
self._pos = QCursor.pos()
text = tooltip_text_for(widget)
self._hide_timer.stop()
if widget is self._widget and self._showing and text == self._text:
return True
if widget is not self._widget:
if self._showing:
self._hide_text()
self._show_timer.stop()
self._track_widget(widget)
self._text = text
if not self._show_timer.isActive():
self._show_timer.start(self.remaining_delay_ms())
return True
def remaining_delay_ms(self) -> int:
"""Wait a full delay when no observed Enter has started the timer.
The style wake-up value is not evidence that time elapsed: Qt uses
a fast path while moving between recently displayed tooltips.
"""
return self.show_delay_ms
def _show_now(self) -> None:
"""The pointer rested long enough. Put the text on screen."""
self._show_timer.stop()
if not tooltips_enabled():
return
widget = self._widget
if widget is None:
return
try:
if not widget.isVisible():
return
except Exception: # noqa: BLE001
return
if not self._text:
self._replay(widget)
return
try:
QToolTip.showText(self._pos, self._text, None)
except Exception: # noqa: BLE001
LOG.debug("could not show a tooltip", exc_info=True)
return
self._showing = True
def _replay(self, widget) -> None:
"""Hand the widget back the event, now that the wait is over.
Nothing in the widget's own chain of parents has a tooltip, so the
text -- if there is any -- belongs to something INSIDE the widget:
a table cell, a header section, a list row, all of which Qt answers
from ``Qt::ToolTipRole`` in the widget's own ``event``. Swallowing
that would silently take those tooltips away, and showing it at the
moment of the hover would leave them the only fast ones left. So
the event is sent again, after the wait, with the filter standing
aside for exactly that one delivery.
"""
from PySide6.QtGui import QHelpEvent
self._track_widget(None)
self._text = ""
try:
local = widget.mapFromGlobal(self._pos)
again = QHelpEvent(QEvent.Type.ToolTip, local, self._pos)
except Exception: # noqa: BLE001
return
self._replaying = True
try:
QApplication.sendEvent(widget, again)
except Exception: # noqa: BLE001
LOG.debug("could not replay a tooltip event", exc_info=True)
finally:
self._replaying = False
def _start_the_linger(self) -> None:
"""The pointer left the widget; give it :data:`LINGER_MS` to return."""
self._show_timer.stop()
if not self._showing:
self._track_widget(None)
self._text = ""
return
self._hide_timer.start(self.linger_ms)
def _hide_if_the_pointer_left(self) -> None:
"""Hide, unless the pointer is resting on the tooltip itself.
A tooltip the reader has moved onto is a tooltip they are reading.
Qt gives the text its own top-level widget, so asking which widget
is under the pointer is enough to tell the two cases apart.
"""
if self._pointer_is_on_the_tooltip():
self._hide_timer.start(self.linger_ms)
return
self.hide_now()
def _pointer_is_on_the_tooltip(self) -> bool:
"""Whether the window under the pointer is a tooltip window.
The mask is not decoration. ``Qt::ToolTip`` is ``Popup | Sheet``,
and both of those carry the ``Window`` bit, so a plain ``flags &
ToolTip`` test is true of EVERY ordinary window -- which made the
tooltip refuse to hide at all while any window sat under the
pointer. Only the masked comparison asks the intended question.
"""
try:
under = QApplication.widgetAt(QCursor.pos())
except Exception: # noqa: BLE001
return False
if under is None:
return False
window = under.window()
try:
flags = window.windowFlags()
kind = flags & Qt.WindowType.WindowType_Mask
except Exception: # noqa: BLE001
return False
return kind == Qt.WindowType.ToolTip
def _hide_text(self) -> None:
"""Hide the tip on screen, if any, and note that none is showing."""
try:
QToolTip.hideText()
except Exception: # noqa: BLE001
LOG.debug("could not hide a tooltip", exc_info=True)
self._showing = False
def hide_now(self) -> None:
"""Take any tooltip away at once and forget what was hovered."""
self._show_timer.stop()
self._hide_timer.stop()
if self._showing:
self._hide_text()
self._track_widget(None)
self._text = ""