1 | /* |
2 | This file is part of the KDE project |
3 | SPDX-FileCopyrightText: 2021 Felix Ernst <fe.a.ernst@gmail.com> |
4 | |
5 | SPDX-License-Identifier: LGPL-2.1-or-later OR BSD-2-Clause |
6 | */ |
7 | |
8 | #include "ktooltiphelper.h" |
9 | #include "ktooltiphelper_p.h" |
10 | |
11 | #include <KLocalizedString> |
12 | |
13 | #include <QAction> |
14 | #include <QApplication> |
15 | #include <QCursor> |
16 | #include <QDesktopServices> |
17 | #include <QHelpEvent> |
18 | #include <QMenu> |
19 | #include <QStyle> |
20 | #include <QToolButton> |
21 | #include <QToolTip> |
22 | #include <QWhatsThis> |
23 | #include <QWhatsThisClickedEvent> |
24 | #include <QWindow> |
25 | #include <QtGlobal> |
26 | |
27 | KToolTipHelper *KToolTipHelper::instance() |
28 | { |
29 | return KToolTipHelperPrivate::instance(); |
30 | } |
31 | |
32 | KToolTipHelper *KToolTipHelperPrivate::instance() |
33 | { |
34 | if (!s_instance) { |
35 | s_instance = new KToolTipHelper(qApp); |
36 | } |
37 | return s_instance; |
38 | } |
39 | |
40 | KToolTipHelper::KToolTipHelper(QObject *parent) |
41 | : QObject{parent} |
42 | , d{new KToolTipHelperPrivate(this)} |
43 | { |
44 | } |
45 | |
46 | KToolTipHelperPrivate::KToolTipHelperPrivate(KToolTipHelper *qq) |
47 | : q{qq} |
48 | { |
49 | m_toolTipTimeout.setSingleShot(true); |
50 | connect(sender: &m_toolTipTimeout, signal: &QTimer::timeout, context: this, slot: &KToolTipHelperPrivate::postToolTipEventIfCursorDidntMove); |
51 | } |
52 | |
53 | KToolTipHelper::~KToolTipHelper() = default; |
54 | |
55 | KToolTipHelperPrivate::~KToolTipHelperPrivate() = default; |
56 | |
57 | bool KToolTipHelper::eventFilter(QObject *watched, QEvent *event) |
58 | { |
59 | return d->eventFilter(watched, event); |
60 | } |
61 | |
62 | bool KToolTipHelperPrivate::eventFilter(QObject *watched, QEvent *event) |
63 | { |
64 | switch (event->type()) { |
65 | case QEvent::Hide: |
66 | return handleHideEvent(watched, event); |
67 | case QEvent::KeyPress: |
68 | return handleKeyPressEvent(event); |
69 | case QEvent::ToolTip: |
70 | return handleToolTipEvent(watched, helpEvent: static_cast<QHelpEvent *>(event)); |
71 | case QEvent::WhatsThisClicked: |
72 | return handleWhatsThisClickedEvent(event); |
73 | default: |
74 | return false; |
75 | } |
76 | } |
77 | |
78 | const QString KToolTipHelper::whatsThisHintOnly() |
79 | { |
80 | return KToolTipHelperPrivate::whatsThisHintOnly(); |
81 | } |
82 | |
83 | const QString KToolTipHelperPrivate::whatsThisHintOnly() |
84 | { |
85 | return QStringLiteral("tooltip bug" ); // if a user ever sees this, there is a bug somewhere. |
86 | } |
87 | |
88 | bool KToolTipHelperPrivate::handleHideEvent(QObject *watched, QEvent *event) |
89 | { |
90 | if (event->spontaneous()) { |
91 | return false; |
92 | } |
93 | const QMenu * = qobject_cast<QMenu *>(object: watched); |
94 | if (!menu) { |
95 | return false; |
96 | } |
97 | |
98 | m_cursorGlobalPosWhenLastMenuHid = QCursor::pos(); |
99 | m_toolTipTimeout.start(msec: menu->style()->styleHint(stylehint: QStyle::SH_ToolTip_WakeUpDelay, opt: nullptr, widget: menu)); |
100 | return false; |
101 | } |
102 | |
103 | bool KToolTipHelperPrivate::handleKeyPressEvent(QEvent *event) |
104 | { |
105 | if (!QToolTip::isVisible() || static_cast<QKeyEvent *>(event)->key() != Qt::Key_Shift || !m_widget) { |
106 | return false; |
107 | } |
108 | |
109 | if (!m_lastToolTipWasExpandable) { |
110 | return false; |
111 | } |
112 | |
113 | QToolTip::hideText(); |
114 | // We need to explicitly hide the tooltip window before showing the whatsthis because hideText() |
115 | // runs a timer before hiding. On Wayland when hiding a popup Qt will close all popups opened after |
116 | // it, including the whatsthis popup here. Unfortunately we can't access the tooltip window/widget |
117 | // directly so we search for it below. |
118 | Q_ASSERT(QApplication::focusWindow()); |
119 | const auto windows = QGuiApplication::allWindows(); |
120 | auto it = std::find_if(first: windows.begin(), last: windows.end(), pred: [](const QWindow *window) { |
121 | return window->type() == Qt::ToolTip && QGuiApplication::focusWindow()->isAncestorOf(child: window); |
122 | }); |
123 | if (it != windows.end()) { |
124 | (*it)->setVisible(false); |
125 | } |
126 | |
127 | if (QMenu * = qobject_cast<QMenu *>(object: m_widget)) { |
128 | if (m_action) { |
129 | // The widget displaying the whatsThis() text tries to avoid covering the QWidget |
130 | // given as the third parameter of QWhatsThis::showText(). Normally we would have |
131 | // menu as the third parameter but because QMenus are quite big the text panel |
132 | // oftentimes fails to find a nice position around it and will instead cover |
133 | // the hovered action itself! To avoid this we give a smaller positioningHelper-widget |
134 | // as the third parameter which only has the size of the hovered menu action entry. |
135 | QWidget *positioningHelper = new QWidget(menu); // Needs to be alive as long as the help is shown or hyperlinks can't be opened. |
136 | positioningHelper->setGeometry(menu->actionGeometry(m_action)); |
137 | QWhatsThis::showText(pos: m_lastExpandableToolTipGlobalPos, text: m_action->whatsThis(), w: positioningHelper); |
138 | connect(sender: menu, signal: &QMenu::aboutToHide, context: positioningHelper, slot: &QObject::deleteLater); |
139 | } |
140 | return true; |
141 | } |
142 | QWhatsThis::showText(pos: m_lastExpandableToolTipGlobalPos, text: m_widget->whatsThis(), w: m_widget); |
143 | return true; |
144 | } |
145 | |
146 | bool KToolTipHelperPrivate::handleMenuToolTipEvent(QMenu *, QHelpEvent *helpEvent) |
147 | { |
148 | Q_CHECK_PTR(helpEvent); |
149 | Q_CHECK_PTR(menu); |
150 | |
151 | m_action = menu->actionAt(helpEvent->pos()); |
152 | if (!m_action || (m_action->menu() && !m_action->menu()->isEmpty())) { |
153 | // Do not show a tooltip when there is a menu since they will compete space-wise. |
154 | QToolTip::hideText(); |
155 | return false; |
156 | } |
157 | |
158 | // All actions have their text as a tooltip by default. |
159 | // We only want to display the tooltip text if it isn't identical |
160 | // to the already visible text in the menu. |
161 | const bool explicitTooltip = !isTextSimilar(a: m_action->iconText(), b: m_action->toolTip()); |
162 | // We only want to show the whatsThisHint in a tooltip if the whatsThis isn't empty. |
163 | const bool emptyWhatsThis = m_action->whatsThis().isEmpty(); |
164 | if (!explicitTooltip && emptyWhatsThis) { |
165 | QToolTip::hideText(); |
166 | return false; |
167 | } |
168 | |
169 | // Calculate a nice location for the tooltip so it doesn't unnecessarily cover |
170 | // a part of the menu. |
171 | const QRect actionGeometry = menu->actionGeometry(m_action); |
172 | const int xOffset = menu->layoutDirection() == Qt::RightToLeft ? 0 : actionGeometry.width(); |
173 | const QPoint toolTipPosition(helpEvent->globalX() - helpEvent->x() + xOffset, |
174 | helpEvent->globalY() - helpEvent->y() + actionGeometry.y() - actionGeometry.height() / 2); |
175 | |
176 | if (explicitTooltip) { |
177 | if (emptyWhatsThis || isTextSimilar(a: m_action->whatsThis(), b: m_action->toolTip())) { |
178 | if (m_action->toolTip() != whatsThisHintOnly()) { |
179 | QToolTip::showText(pos: toolTipPosition, text: m_action->toolTip(), w: m_widget, rect: actionGeometry); |
180 | } |
181 | } else { |
182 | showExpandableToolTip(globalPos: toolTipPosition, toolTip: m_action->toolTip(), rect: actionGeometry); |
183 | } |
184 | return true; |
185 | } |
186 | Q_ASSERT(!m_action->whatsThis().isEmpty()); |
187 | showExpandableToolTip(globalPos: toolTipPosition, toolTip: QString(), rect: actionGeometry); |
188 | return true; |
189 | } |
190 | |
191 | bool KToolTipHelperPrivate::handleToolTipEvent(QObject *watched, QHelpEvent *helpEvent) |
192 | { |
193 | if (auto watchedWidget = qobject_cast<QWidget *>(o: watched)) { |
194 | m_widget = watchedWidget; |
195 | } else { |
196 | // There are fringe cases in which QHelpEvents are sent to QObjects that are not QWidgets |
197 | // e.g. objects inheriting from QSystemTrayIcon. |
198 | // We do not know how to handle those so we return false. |
199 | return false; |
200 | } |
201 | |
202 | m_lastToolTipWasExpandable = false; |
203 | |
204 | bool areToolTipAndWhatsThisSimilar = isTextSimilar(a: m_widget->whatsThis(), b: m_widget->toolTip()); |
205 | |
206 | if (QToolButton *toolButton = qobject_cast<QToolButton *>(object: m_widget)) { |
207 | if (const QAction *action = toolButton->defaultAction()) { |
208 | if (!action->shortcut().isEmpty() && action->toolTip() != whatsThisHintOnly()) { |
209 | // Because we set the tool button's tooltip below, we must re-check the whats this, because the shortcut |
210 | // would technically make it unique. |
211 | areToolTipAndWhatsThisSimilar = isTextSimilar(a: action->whatsThis(), b: action->toolTip()); |
212 | |
213 | toolButton->setToolTip(i18nc("@info:tooltip %1 is the tooltip of an action, %2 is its keyboard shorcut" , |
214 | "%1 (%2)" , |
215 | action->toolTip(), |
216 | action->shortcut().toString(QKeySequence::NativeText))); |
217 | // Do not replace the brackets in the above i18n-call with <shortcut> tags from |
218 | // KUIT because mixing KUIT with HTML is not allowed and %1 could be anything. |
219 | |
220 | // We don't show the tooltip here because aside from adding the keyboard shortcut |
221 | // the QToolButton can now be handled like the tooltip event for any other widget. |
222 | } |
223 | } |
224 | } else if (QMenu * = qobject_cast<QMenu *>(object: m_widget)) { |
225 | return handleMenuToolTipEvent(menu, helpEvent); |
226 | } |
227 | |
228 | while (m_widget->toolTip().isEmpty()) { |
229 | m_widget = m_widget->parentWidget(); |
230 | if (!m_widget) { |
231 | return false; |
232 | } |
233 | } |
234 | |
235 | if (m_widget->whatsThis().isEmpty() || areToolTipAndWhatsThisSimilar) { |
236 | if (m_widget->toolTip() == whatsThisHintOnly()) { |
237 | return true; |
238 | } |
239 | return false; |
240 | } |
241 | showExpandableToolTip(globalPos: helpEvent->globalPos(), toolTip: m_widget->toolTip()); |
242 | return true; |
243 | } |
244 | |
245 | bool KToolTipHelperPrivate::handleWhatsThisClickedEvent(QEvent *event) |
246 | { |
247 | event->accept(); |
248 | const auto whatsThisClickedEvent = static_cast<QWhatsThisClickedEvent *>(event); |
249 | QDesktopServices::openUrl(url: QUrl(whatsThisClickedEvent->href())); |
250 | return true; |
251 | } |
252 | |
253 | void KToolTipHelperPrivate::postToolTipEventIfCursorDidntMove() const |
254 | { |
255 | const QPoint globalCursorPos = QCursor::pos(); |
256 | if (globalCursorPos != m_cursorGlobalPosWhenLastMenuHid) { |
257 | return; |
258 | } |
259 | |
260 | const auto widgetUnderCursor = qApp->widgetAt(p: globalCursorPos); |
261 | // We only want a behaviour change for QMenus. |
262 | if (qobject_cast<QMenu *>(object: widgetUnderCursor)) { |
263 | qGuiApp->postEvent(receiver: widgetUnderCursor, event: new QHelpEvent(QEvent::ToolTip, widgetUnderCursor->mapFromGlobal(globalCursorPos), globalCursorPos)); |
264 | } |
265 | } |
266 | |
267 | void KToolTipHelperPrivate::showExpandableToolTip(const QPoint &globalPos, const QString &toolTip, const QRect &rect) |
268 | { |
269 | m_lastExpandableToolTipGlobalPos = QPoint(globalPos); |
270 | m_lastToolTipWasExpandable = true; |
271 | |
272 | if (toolTip.isEmpty() || toolTip == whatsThisHintOnly()) { |
273 | const QString whatsThisHint = |
274 | // i18n: Pressing Shift will show a longer message with contextual info |
275 | // about the thing the tooltip was invoked for. If there is no good way to translate |
276 | // the message, translating "Press Shift to learn more." would also mostly fit what |
277 | // is supposed to be expressed here. |
278 | i18nc("@info:tooltip" , |
279 | "<small><font color=\"%1\">Press <b>Shift</b> for more Info.</font></small>" , |
280 | qApp->palette().placeholderText().color().name()); |
281 | QToolTip::showText(pos: m_lastExpandableToolTipGlobalPos, text: whatsThisHint, w: m_widget, rect); |
282 | } else { |
283 | const QString toolTipWithHint = QStringLiteral("<qt>" ) + |
284 | // i18n: The 'Press Shift for more' message is added to tooltips that have an |
285 | // available whatsthis help message. Pressing Shift will show this more exhaustive message. |
286 | // It is particularly important to keep this translation short because: |
287 | // 1. A longer translation will increase the size of *every* tooltip that gets this hint |
288 | // added e.g. a two word tooltip followed by a four word hint. |
289 | // 2. The purpose of this hint is so we can keep the tooltip shorter than it would have to |
290 | // be if we couldn't refer to the message that appears when pressing Shift. |
291 | // |
292 | // %1 can be any tooltip. <br/> produces a linebreak. The other things between < and > are |
293 | // styling information. The word "more" refers to "information". |
294 | i18nc("@info:tooltip keep short" , |
295 | "%1<br/><small><font color=\"%2\">Press <b>Shift</b> for more.</font></small>" , |
296 | toolTip, |
297 | qApp->palette().placeholderText().color().name()) |
298 | + QStringLiteral("</qt>" ); |
299 | // Do not replace above HTML tags with KUIT because mixing HTML and KUIT is not allowed and |
300 | // we can not know what kind of markup the tooltip in %1 contains. |
301 | QToolTip::showText(pos: m_lastExpandableToolTipGlobalPos, text: toolTipWithHint, w: m_widget, rect); |
302 | } |
303 | } |
304 | |
305 | KToolTipHelper *KToolTipHelperPrivate::s_instance = nullptr; |
306 | |
307 | bool isTextSimilar(const QString &a, const QString &b) |
308 | { |
309 | int i = -1; |
310 | int j = -1; |
311 | do { |
312 | i++; |
313 | j++; |
314 | // Both of these QStrings are considered equal if their only differences are '&' and '.' chars. |
315 | // Now move both of their indices to the next char that is neither '&' nor '.'. |
316 | while (i < a.size() && (a.at(i) == QLatin1Char('&') || a.at(i) == QLatin1Char('.'))) { |
317 | i++; |
318 | } |
319 | while (j < b.size() && (b.at(i: j) == QLatin1Char('&') || b.at(i: j) == QLatin1Char('.'))) { |
320 | j++; |
321 | } |
322 | |
323 | if (i >= a.size()) { |
324 | return j >= b.size(); |
325 | } |
326 | if (j >= b.size()) { |
327 | return i >= a.size(); |
328 | } |
329 | } while (a.at(i) == b.at(i: j)); |
330 | return false; // We have found a difference. |
331 | } |
332 | |
333 | #include "moc_ktooltiphelper.cpp" |
334 | #include "moc_ktooltiphelper_p.cpp" |
335 | |