Coverage for paintmaskanimator/tooltip.py: 34%
168 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1"""Rounded, theme-aware tooltips that replace Qt's rectangular tooltip box.
3Qt draws tooltips in a plain rectangular window, so a style sheet cannot give
4them rounded corners or a shadow. :class:`ToolTipManager` is installed on the
5``QApplication`` and answers ``QEvent.ToolTip`` itself for ordinary widgets,
6item views, tab bars and menus, showing :class:`ToolTipPopup` instead. Anything
7it does not recognise falls through to Qt's own tooltip (styled by the global
8style sheet), so no tooltip is ever lost.
10Tooltips written with :func:`titled` get a bold first line (the command name)
11above a muted description.
12"""
13import html
14import re
16import shiboken6
18from PySide6.QtCore import QEvent, QObject, QPoint, QRectF, Qt, QTimer
19from PySide6.QtGui import QColor, QGuiApplication, QPainter, QPainterPath, QPen
20from PySide6.QtWidgets import (
21 QAbstractItemView, QApplication, QLabel, QMenu, QTabBar, QVBoxLayout, QWidget,
22)
24from . import theme
25from .logging_setup import get_logger
27log = get_logger(__name__)
29_TITLED = re.compile(r"^<b>(.*?)</b><br>(.*)$", re.DOTALL)
30_SHADOW = 8 # px reserved around the panel for the soft shadow
31_RADIUS = 8
32_MAX_WIDTH = 320
33_HIDE_AFTER_MS = 12000
36def titled(title, detail):
37 """A tooltip whose first line is the command name, shown bold."""
38 body = html.escape(str(detail)).replace("\n", "<br>")
39 return f"<b>{html.escape(str(title))}</b><br>{body}"
42class ToolTipPopup(QWidget):
43 """Frameless translucent window drawing a rounded panel with a shadow."""
45 def __init__(self):
46 super().__init__(None, Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint)
47 self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
48 self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
49 self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
50 layout = QVBoxLayout(self)
51 layout.setContentsMargins(_SHADOW + 10, _SHADOW + 7, _SHADOW + 10, _SHADOW + 8)
52 layout.setSpacing(2)
53 self.title = QLabel(self)
54 self.body = QLabel(self)
55 for label in (self.title, self.body):
56 label.setTextFormat(Qt.TextFormat.AutoText)
57 label.setWordWrap(True)
58 label.setMaximumWidth(_MAX_WIDTH)
59 layout.addWidget(label)
60 self._hide_timer = QTimer(self)
61 self._hide_timer.setSingleShot(True)
62 self._hide_timer.timeout.connect(self.hide)
63 self.text = ""
65 def set_text(self, text):
66 self.text = text
67 c = theme.palette()
68 match = _TITLED.match(text)
69 if match:
70 title, body = match.group(1), match.group(2)
71 else:
72 title, body = "", text
73 self.title.setVisible(bool(title))
74 self.title.setText(title)
75 self.title.setStyleSheet(
76 f"color:{c['text']};font-weight:bold;background:transparent;"
77 )
78 self.body.setText(body)
79 self.body.setStyleSheet(
80 f"color:{c['text_muted'] if title else c['text']};background:transparent;"
81 )
82 self.body.setVisible(bool(body))
83 # Short tips stay on one line; long ones use the full maximum width
84 # before wrapping (a wrapping QLabel otherwise picks a narrow width).
85 for label in (self.title, self.body):
86 label.setWordWrap(False)
87 label.setMinimumWidth(0)
88 if label.sizeHint().width() > _MAX_WIDTH: 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true
89 label.setWordWrap(True)
90 label.setMinimumWidth(_MAX_WIDTH)
91 self.adjustSize()
92 self.update()
94 def show_at(self, global_pos):
95 screen = QGuiApplication.screenAt(global_pos) or QGuiApplication.primaryScreen()
96 pos = QPoint(global_pos.x() + 4, global_pos.y() + 14)
97 if screen is not None:
98 area = screen.availableGeometry()
99 if pos.x() + self.width() > area.right():
100 pos.setX(max(area.left(), area.right() - self.width()))
101 if pos.y() + self.height() > area.bottom():
102 # Flip above the cursor rather than covering it.
103 pos.setY(global_pos.y() - self.height() - 4)
104 # The window includes the shadow margin; place the panel itself at pos.
105 self.move(pos - QPoint(_SHADOW, _SHADOW))
106 self.show()
107 self.raise_()
108 self._hide_timer.start(_HIDE_AFTER_MS)
110 def paintEvent(self, event):
111 c = theme.palette()
112 dark = theme.resolved_theme() == "dark"
113 painter = QPainter(self)
114 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
115 panel = QRectF(self.rect()).adjusted(_SHADOW, _SHADOW - 2, -_SHADOW, -_SHADOW - 2)
116 # Soft shadow: a few widening, fading rounded rectangles.
117 for step in range(_SHADOW, 0, -1):
118 alpha = int((70 if dark else 34) * (1 - step / _SHADOW) ** 2)
119 shadow = QPainterPath()
120 shadow.addRoundedRect(
121 panel.adjusted(-step, -step + 2, step, step + 2),
122 _RADIUS + step, _RADIUS + step,
123 )
124 painter.fillPath(shadow, QColor(0, 0, 0, alpha))
125 path = QPainterPath()
126 path.addRoundedRect(panel, _RADIUS, _RADIUS)
127 background = QColor(c["surface_alt"] if dark else c["surface"])
128 background.setAlpha(250)
129 painter.fillPath(path, background)
130 painter.setPen(QPen(QColor(c["border"]), 1))
131 painter.drawPath(path)
132 painter.end()
135class ToolTipManager(QObject):
136 """Application-wide event filter that shows :class:`ToolTipPopup`."""
138 def __init__(self, parent=None):
139 super().__init__(parent)
140 self.popup = ToolTipPopup()
141 self._source = None
142 self._source_key = None
144 # -- text lookup -------------------------------------------------------
145 @staticmethod
146 def _tooltip_for(widget, pos):
147 """(text, key) for ``pos`` in ``widget``; text is None if not ours."""
148 parent = widget.parentWidget()
149 if isinstance(parent, QAbstractItemView) and widget is parent.viewport():
150 index = parent.indexAt(pos)
151 if not index.isValid():
152 return "", None
153 text = index.data(Qt.ItemDataRole.ToolTipRole)
154 return (str(text) if text else ""), (index.row(), index.column())
155 if isinstance(widget, QAbstractItemView):
156 return None, None # headers etc.: let Qt handle them
157 if isinstance(widget, QTabBar):
158 tab = widget.tabAt(pos)
159 return (widget.tabToolTip(tab) if tab >= 0 else ""), tab
160 if isinstance(widget, QMenu):
161 if not widget.toolTipsVisible():
162 return "", None
163 action = widget.actionAt(pos)
164 if action is None or action.toolTip() == action.text().replace("&", ""):
165 return "", None
166 return action.toolTip(), id(action)
167 return widget.toolTip(), None
169 # -- event handling ----------------------------------------------------
170 def eventFilter(self, watched, event):
171 # Installed on the whole application: a failure here must never break
172 # event delivery for the rest of the app, so fall back to Qt's tooltip.
173 if not shiboken6.isValid(self.popup):
174 # The popup is destroyed with the application on exit.
175 self._uninstall()
176 return False
177 try:
178 return self._filter(watched, event)
179 except Exception: # noqa: BLE001 - see comment above
180 log.exception("custom tooltip failed; falling back to Qt's tooltip")
181 self._uninstall()
182 return False
184 def _uninstall(self):
185 app = QApplication.instance()
186 if app is not None:
187 app.removeEventFilter(self)
189 def _filter(self, watched, event):
190 kind = event.type()
191 if kind == QEvent.Type.ToolTip and isinstance(watched, QWidget):
192 text, key = self._tooltip_for(watched, event.pos())
193 if not text:
194 # None: not ours (Qt handles it). Empty: Qt passes the event on
195 # to the parent widget, which reaches this filter again.
196 self.hide()
197 return False
198 if self.popup.isVisible() and watched is self._source and key == self._source_key:
199 return True
200 self._source, self._source_key = watched, key
201 self.popup.set_text(text)
202 self.popup.show_at(event.globalPos())
203 return True
204 if self.popup.isVisible():
205 if kind in (
206 QEvent.Type.MouseButtonPress, QEvent.Type.MouseButtonDblClick,
207 QEvent.Type.Wheel, QEvent.Type.KeyPress,
208 QEvent.Type.WindowDeactivate, QEvent.Type.ApplicationStateChange,
209 ):
210 self.hide()
211 elif watched is self._source and kind in (QEvent.Type.Leave, QEvent.Type.Hide):
212 self.hide()
213 elif (
214 watched is self._source
215 and kind == QEvent.Type.MouseMove
216 and self._source_key is not None
217 ):
218 # Moving to another item or tab: drop the stale tip.
219 _text, key = self._tooltip_for(watched, event.position().toPoint())
220 if key != self._source_key:
221 self.hide()
222 return False
224 def hide(self):
225 if shiboken6.isValid(self.popup):
226 self.popup.hide()
227 self._source = None
228 self._source_key = None
231_manager = None
234def install(app=None):
235 """Install the custom tooltip handler once for the application."""
236 global _manager
237 app = app or QApplication.instance()
238 if app is None or _manager is not None:
239 return _manager
240 _manager = ToolTipManager(app)
241 app.installEventFilter(_manager)
242 return _manager