KXmlGui

ktooltiphelper.cpp
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
28KToolTipHelper *KToolTipHelper::instance()
29{
30 return KToolTipHelperPrivate::instance();
31}
32
33KToolTipHelper *KToolTipHelperPrivate::instance()
34{
35 if (!s_instance) {
36 s_instance = new KToolTipHelper(qApp);
37 }
38 return s_instance;
39}
40
41KToolTipHelper::KToolTipHelper(QObject *parent)
42 : QObject{parent}
43 , d{new KToolTipHelperPrivate(this)}
44{
45}
46
47KToolTipHelperPrivate::KToolTipHelperPrivate(KToolTipHelper *qq)
48 : q{qq}
49{
50 m_toolTipTimeout.setSingleShot(true);
51 connect(&m_toolTipTimeout, &QTimer::timeout, this, &KToolTipHelperPrivate::postToolTipEventIfCursorDidntMove);
52}
53
54KToolTipHelper::~KToolTipHelper() = default;
55
56KToolTipHelperPrivate::~KToolTipHelperPrivate() = default;
57
59{
60 return d->eventFilter(watched, event);
61}
62
63bool KToolTipHelperPrivate::eventFilter(QObject *watched, QEvent *event)
64{
65 switch (event->type()) {
66 case QEvent::Hide:
67 return handleHideEvent(watched, event);
69 return handleKeyPressEvent(event);
70 case QEvent::ToolTip:
71 return handleToolTipEvent(watched, static_cast<QHelpEvent *>(event));
73 return handleWhatsThisClickedEvent(event);
74 default:
75 return false;
76 }
77}
78
80{
81 return KToolTipHelperPrivate::whatsThisHintOnly();
82}
83
84const QString KToolTipHelperPrivate::whatsThisHintOnly()
85{
86 return QStringLiteral("tooltip bug"); // if a user ever sees this, there is a bug somewhere.
87}
88
89bool KToolTipHelperPrivate::handleHideEvent(QObject *watched, QEvent *event)
90{
91 if (event->spontaneous()) {
92 return false;
93 }
94 const QMenu *menu = qobject_cast<QMenu *>(watched);
95 if (!menu) {
96 return false;
97 }
98
99 m_cursorGlobalPosWhenLastMenuHid = QCursor::pos();
100 m_toolTipTimeout.start(menu->style()->styleHint(QStyle::SH_ToolTip_WakeUpDelay, nullptr, menu));
101 return false;
102}
103
104bool 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
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(windows.begin(), windows.end(), [](const QWindow *window) {
122 return window->type() == Qt::ToolTip && QGuiApplication::focusWindow()->isAncestorOf(window);
123 });
124 if (it != windows.end()) {
125 (*it)->setVisible(false);
126 }
127
128 if (QMenu *menu = qobject_cast<QMenu *>(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(m_lastExpandableToolTipGlobalPos, m_action->whatsThis(), positioningHelper);
139 connect(menu, &QMenu::aboutToHide, positioningHelper, &QObject::deleteLater);
140 }
141 return true;
142 }
143 QWhatsThis::showText(m_lastExpandableToolTipGlobalPos, m_widget->whatsThis(), m_widget);
144 return true;
145}
146
147bool KToolTipHelperPrivate::handleMenuToolTipEvent(QMenu *menu, 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.
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(m_action->iconText(), 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) {
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(m_action->whatsThis(), m_action->toolTip())) {
179 if (m_action->toolTip() != whatsThisHintOnly()) {
180 QToolTip::showText(toolTipPosition, m_action->toolTip(), m_widget, actionGeometry);
181 }
182 } else {
183 showExpandableToolTip(toolTipPosition, m_action->toolTip(), actionGeometry);
184 }
185 return true;
186 }
187 Q_ASSERT(!m_action->whatsThis().isEmpty());
188 showExpandableToolTip(toolTipPosition, QString(), actionGeometry);
189 return true;
190}
191
192bool KToolTipHelperPrivate::handleToolTipEvent(QObject *watched, QHelpEvent *helpEvent)
193{
194 if (auto watchedWidget = qobject_cast<QWidget *>(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(m_widget->whatsThis(), m_widget->toolTip());
206
207 if (QToolButton *toolButton = qobject_cast<QToolButton *>(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(action->whatsThis(), 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 *menu = qobject_cast<QMenu *>(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(helpEvent->globalPos(), m_widget->toolTip());
243 return true;
244}
245
246bool KToolTipHelperPrivate::handleWhatsThisClickedEvent(QEvent *event)
247{
248 event->accept();
249 const auto whatsThisClickedEvent = static_cast<QWhatsThisClickedEvent *>(event);
250 QDesktopServices::openUrl(QUrl(whatsThisClickedEvent->href()));
251 return true;
252}
253
254void 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(globalCursorPos);
262 // We only want a behaviour change for QMenus.
263 if (qobject_cast<QMenu *>(widgetUnderCursor)) {
264 qGuiApp->postEvent(widgetUnderCursor, new QHelpEvent(QEvent::ToolTip, widgetUnderCursor->mapFromGlobal(globalCursorPos), globalCursorPos));
265 }
266}
267
268void KToolTipHelperPrivate::showExpandableToolTip(const QPoint &globalPos, const QString &toolTip, const QRect &rect)
269{
270 m_lastExpandableToolTipGlobalPos = QPoint(globalPos);
271 m_lastToolTipWasExpandable = true;
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(m_lastExpandableToolTipGlobalPos, whatsThisHint, 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(m_lastExpandableToolTipGlobalPos, toolTipWithHint, m_widget, rect);
300 }
301}
302
303KToolTipHelper *KToolTipHelperPrivate::s_instance = nullptr;
304
305bool 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(j) == QLatin1Char('&') || b.at(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(j));
328 return false; // We have found a difference.
329}
330
331#include "moc_ktooltiphelper.cpp"
332#include "moc_ktooltiphelper_p.cpp"
QBrush foreground(ForegroundRole=NormalText) const
An event filter used to enhance tooltips.
static const QString whatsThisHintOnly()
Use this to have a widget show "Press Shift for help." as its tooltip.
bool eventFilter(QObject *watched, QEvent *event) override
Filters QEvent::ToolTip if an enhanced tooltip is available for the widget.
QString i18nc(const char *context, const char *text, const TYPE &arg...)
AKONADI_CALENDAR_EXPORT KCalendarCore::Event::Ptr event(const Akonadi::Item &item)
const QColor & color() const const
QString name(NameFormat format) const const
QPoint pos()
bool openUrl(const QUrl &url)
QWindowList allWindows()
QWindow * focusWindow()
const QPoint & globalPos() const const
int globalX() const const
int globalY() const const
const QPoint & pos() const const
int x() const const
int y() const const
void aboutToHide()
QAction * actionAt(const QPoint &pt) const const
QRect actionGeometry(QAction *act) const const
void deleteLater()
virtual bool event(QEvent *e)
int height() const const
int width() const const
int y() const const
const QChar at(qsizetype position) const const
bool isEmpty() const const
qsizetype size() const const
SH_ToolTip_WakeUpDelay
virtual int styleHint(StyleHint hint, const QStyleOption *option, const QWidget *widget, QStyleHintReturn *returnData) const const=0
Key_Shift
RightToLeft
QFuture< ArgsType< Signal > > connect(Sender *sender, Signal signal)
void timeout()
void hideText()
bool isVisible()
void showText(const QPoint &pos, const QString &text, QWidget *w, const QRect &rect, int msecDisplayTime)
void showText(const QPoint &pos, const QString &text, QWidget *w)
void setGeometry(const QRect &)
QStyle * style() const const
This file is part of the KDE documentation.
Documentation copyright © 1996-2024 The KDE developers.
Generated on Mon Nov 18 2024 12:19:32 by doxygen 1.12.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.