Coverage for paintmaskanimator/tooltip.py: 34%

168 statements  

« 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. 

2 

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. 

9 

10Tooltips written with :func:`titled` get a bold first line (the command name) 

11above a muted description. 

12""" 

13import html 

14import re 

15 

16import shiboken6 

17 

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) 

23 

24from . import theme 

25from .logging_setup import get_logger 

26 

27log = get_logger(__name__) 

28 

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 

34 

35 

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}" 

40 

41 

42class ToolTipPopup(QWidget): 

43 """Frameless translucent window drawing a rounded panel with a shadow.""" 

44 

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 = "" 

64 

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() 

93 

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) 

109 

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() 

133 

134 

135class ToolTipManager(QObject): 

136 """Application-wide event filter that shows :class:`ToolTipPopup`.""" 

137 

138 def __init__(self, parent=None): 

139 super().__init__(parent) 

140 self.popup = ToolTipPopup() 

141 self._source = None 

142 self._source_key = None 

143 

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 

168 

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 

183 

184 def _uninstall(self): 

185 app = QApplication.instance() 

186 if app is not None: 

187 app.removeEventFilter(self) 

188 

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 

223 

224 def hide(self): 

225 if shiboken6.isValid(self.popup): 

226 self.popup.hide() 

227 self._source = None 

228 self._source_key = None 

229 

230 

231_manager = None 

232 

233 

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 

243