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
27KToolTipHelper *KToolTipHelper::instance()
28{
29 return KToolTipHelperPrivate::instance();
30}
31
32KToolTipHelper *KToolTipHelperPrivate::instance()
33{
34 if (!s_instance) {
35 s_instance = new KToolTipHelper(qApp);
36 }
37 return s_instance;
38}
39
40KToolTipHelper::KToolTipHelper(QObject *parent)
41 : QObject{parent}
42 , d{new KToolTipHelperPrivate(this)}
43{
44}
45
46KToolTipHelperPrivate::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
53KToolTipHelper::~KToolTipHelper() = default;
54
55KToolTipHelperPrivate::~KToolTipHelperPrivate() = default;
56
57bool KToolTipHelper::eventFilter(QObject *watched, QEvent *event)
58{
59 return d->eventFilter(watched, event);
60}
61
62bool 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
78const QString KToolTipHelper::whatsThisHintOnly()
79{
80 return KToolTipHelperPrivate::whatsThisHintOnly();
81}
82
83const QString KToolTipHelperPrivate::whatsThisHintOnly()
84{
85 return QStringLiteral("tooltip bug"); // if a user ever sees this, there is a bug somewhere.
86}
87
88bool KToolTipHelperPrivate::handleHideEvent(QObject *watched, QEvent *event)
89{
90 if (event->spontaneous()) {
91 return false;
92 }
93 const QMenu *menu = 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
103bool 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 *menu = 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
146bool KToolTipHelperPrivate::handleMenuToolTipEvent(QMenu *menu, 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
191bool 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 *menu = 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
245bool 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
253void 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
267void 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
305KToolTipHelper *KToolTipHelperPrivate::s_instance = nullptr;
306
307bool 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

source code of kxmlgui/src/ktooltiphelper.cpp