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