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.
virtual 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)
QList< T > findChildren(Qt::FindChildOptions options) const const
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 Tue Mar 26 2024 11:21:12 by doxygen 1.10.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.