File indexing completed on 2024-04-21 03:59:40

0001 /*
0002     This file is part of the KDE project
0003     SPDX-FileCopyrightText: 2021 Felix Ernst <fe.a.ernst@gmail.com>
0004 
0005     SPDX-License-Identifier: LGPL-2.1-or-later OR BSD-2-Clause
0006 */
0007 
0008 #include "ktooltiphelper.h"
0009 #include "ktooltiphelper_p.h"
0010 
0011 #include <KColorScheme>
0012 #include <KLocalizedString>
0013 
0014 #include <QAction>
0015 #include <QApplication>
0016 #include <QCursor>
0017 #include <QDesktopServices>
0018 #include <QHelpEvent>
0019 #include <QMenu>
0020 #include <QStyle>
0021 #include <QToolButton>
0022 #include <QToolTip>
0023 #include <QWhatsThis>
0024 #include <QWhatsThisClickedEvent>
0025 #include <QWindow>
0026 #include <QtGlobal>
0027 
0028 KToolTipHelper *KToolTipHelper::instance()
0029 {
0030     return KToolTipHelperPrivate::instance();
0031 }
0032 
0033 KToolTipHelper *KToolTipHelperPrivate::instance()
0034 {
0035     if (!s_instance) {
0036         s_instance = new KToolTipHelper(qApp);
0037     }
0038     return s_instance;
0039 }
0040 
0041 KToolTipHelper::KToolTipHelper(QObject *parent)
0042     : QObject{parent}
0043     , d{new KToolTipHelperPrivate(this)}
0044 {
0045 }
0046 
0047 KToolTipHelperPrivate::KToolTipHelperPrivate(KToolTipHelper *qq)
0048     : q{qq}
0049 {
0050     m_toolTipTimeout.setSingleShot(true);
0051     connect(&m_toolTipTimeout, &QTimer::timeout, this, &KToolTipHelperPrivate::postToolTipEventIfCursorDidntMove);
0052 }
0053 
0054 KToolTipHelper::~KToolTipHelper() = default;
0055 
0056 KToolTipHelperPrivate::~KToolTipHelperPrivate() = default;
0057 
0058 bool KToolTipHelper::eventFilter(QObject *watched, QEvent *event)
0059 {
0060     return d->eventFilter(watched, event);
0061 }
0062 
0063 bool KToolTipHelperPrivate::eventFilter(QObject *watched, QEvent *event)
0064 {
0065     switch (event->type()) {
0066     case QEvent::Hide:
0067         return handleHideEvent(watched, event);
0068     case QEvent::KeyPress:
0069         return handleKeyPressEvent(event);
0070     case QEvent::ToolTip:
0071         return handleToolTipEvent(watched, static_cast<QHelpEvent *>(event));
0072     case QEvent::WhatsThisClicked:
0073         return handleWhatsThisClickedEvent(event);
0074     default:
0075         return false;
0076     }
0077 }
0078 
0079 const QString KToolTipHelper::whatsThisHintOnly()
0080 {
0081     return KToolTipHelperPrivate::whatsThisHintOnly();
0082 }
0083 
0084 const QString KToolTipHelperPrivate::whatsThisHintOnly()
0085 {
0086     return QStringLiteral("tooltip bug"); // if a user ever sees this, there is a bug somewhere.
0087 }
0088 
0089 bool KToolTipHelperPrivate::handleHideEvent(QObject *watched, QEvent *event)
0090 {
0091     if (event->spontaneous()) {
0092         return false;
0093     }
0094     const QMenu *menu = qobject_cast<QMenu *>(watched);
0095     if (!menu) {
0096         return false;
0097     }
0098 
0099     m_cursorGlobalPosWhenLastMenuHid = QCursor::pos();
0100     m_toolTipTimeout.start(menu->style()->styleHint(QStyle::SH_ToolTip_WakeUpDelay, nullptr, menu));
0101     return false;
0102 }
0103 
0104 bool KToolTipHelperPrivate::handleKeyPressEvent(QEvent *event)
0105 {
0106     if (!QToolTip::isVisible() || static_cast<QKeyEvent *>(event)->key() != Qt::Key_Shift || !m_widget) {
0107         return false;
0108     }
0109 
0110     if (!m_lastToolTipWasExpandable) {
0111         return false;
0112     }
0113 
0114     QToolTip::hideText();
0115     // We need to explicitly hide the tooltip window before showing the whatsthis because hideText()
0116     // runs a timer before hiding. On Wayland when hiding a popup Qt will close all popups opened after
0117     // it, including the whatsthis popup here. Unfortunately we can't access the tooltip window/widget
0118     // directly so we search for it below.
0119     Q_ASSERT(QApplication::focusWindow());
0120     const auto windows = QGuiApplication::allWindows();
0121     auto it = std::find_if(windows.begin(), windows.end(), [](const QWindow *window) {
0122         return window->type() == Qt::ToolTip && QGuiApplication::focusWindow()->isAncestorOf(window);
0123     });
0124     if (it != windows.end()) {
0125         (*it)->setVisible(false);
0126     }
0127 
0128     if (QMenu *menu = qobject_cast<QMenu *>(m_widget)) {
0129         if (m_action) {
0130             // The widget displaying the whatsThis() text tries to avoid covering the QWidget
0131             // given as the third parameter of QWhatsThis::showText(). Normally we would have
0132             // menu as the third parameter but because QMenus are quite big the text panel
0133             // oftentimes fails to find a nice position around it and will instead cover
0134             // the hovered action itself! To avoid this we give a smaller positioningHelper-widget
0135             // as the third parameter which only has the size of the hovered menu action entry.
0136             QWidget *positioningHelper = new QWidget(menu); // Needs to be alive as long as the help is shown or hyperlinks can't be opened.
0137             positioningHelper->setGeometry(menu->actionGeometry(m_action));
0138             QWhatsThis::showText(m_lastExpandableToolTipGlobalPos, m_action->whatsThis(), positioningHelper);
0139             connect(menu, &QMenu::aboutToHide, positioningHelper, &QObject::deleteLater);
0140         }
0141         return true;
0142     }
0143     QWhatsThis::showText(m_lastExpandableToolTipGlobalPos, m_widget->whatsThis(), m_widget);
0144     return true;
0145 }
0146 
0147 bool KToolTipHelperPrivate::handleMenuToolTipEvent(QMenu *menu, QHelpEvent *helpEvent)
0148 {
0149     Q_CHECK_PTR(helpEvent);
0150     Q_CHECK_PTR(menu);
0151 
0152     m_action = menu->actionAt(helpEvent->pos());
0153     if (!m_action || (m_action->menu() && !m_action->menu()->isEmpty())) {
0154         // Do not show a tooltip when there is a menu since they will compete space-wise.
0155         QToolTip::hideText();
0156         return false;
0157     }
0158 
0159     // All actions have their text as a tooltip by default.
0160     // We only want to display the tooltip text if it isn't identical
0161     // to the already visible text in the menu.
0162     const bool explicitTooltip = !isTextSimilar(m_action->iconText(), m_action->toolTip());
0163     // We only want to show the whatsThisHint in a tooltip if the whatsThis isn't empty.
0164     const bool emptyWhatsThis = m_action->whatsThis().isEmpty();
0165     if (!explicitTooltip && emptyWhatsThis) {
0166         QToolTip::hideText();
0167         return false;
0168     }
0169 
0170     // Calculate a nice location for the tooltip so it doesn't unnecessarily cover
0171     // a part of the menu.
0172     const QRect actionGeometry = menu->actionGeometry(m_action);
0173     const int xOffset = menu->layoutDirection() == Qt::RightToLeft ? 0 : actionGeometry.width();
0174     const QPoint toolTipPosition(helpEvent->globalX() - helpEvent->x() + xOffset,
0175                                  helpEvent->globalY() - helpEvent->y() + actionGeometry.y() - actionGeometry.height() / 2);
0176 
0177     if (explicitTooltip) {
0178         if (emptyWhatsThis || isTextSimilar(m_action->whatsThis(), m_action->toolTip())) {
0179             if (m_action->toolTip() != whatsThisHintOnly()) {
0180                 QToolTip::showText(toolTipPosition, m_action->toolTip(), m_widget, actionGeometry);
0181             }
0182         } else {
0183             showExpandableToolTip(toolTipPosition, m_action->toolTip(), actionGeometry);
0184         }
0185         return true;
0186     }
0187     Q_ASSERT(!m_action->whatsThis().isEmpty());
0188     showExpandableToolTip(toolTipPosition, QString(), actionGeometry);
0189     return true;
0190 }
0191 
0192 bool KToolTipHelperPrivate::handleToolTipEvent(QObject *watched, QHelpEvent *helpEvent)
0193 {
0194     if (auto watchedWidget = qobject_cast<QWidget *>(watched)) {
0195         m_widget = watchedWidget;
0196     } else {
0197         // There are fringe cases in which QHelpEvents are sent to QObjects that are not QWidgets
0198         // e.g. objects inheriting from QSystemTrayIcon.
0199         // We do not know how to handle those so we return false.
0200         return false;
0201     }
0202 
0203     m_lastToolTipWasExpandable = false;
0204 
0205     bool areToolTipAndWhatsThisSimilar = isTextSimilar(m_widget->whatsThis(), m_widget->toolTip());
0206 
0207     if (QToolButton *toolButton = qobject_cast<QToolButton *>(m_widget)) {
0208         if (const QAction *action = toolButton->defaultAction()) {
0209             if (!action->shortcut().isEmpty() && action->toolTip() != whatsThisHintOnly()) {
0210                 // Because we set the tool button's tooltip below, we must re-check the whats this, because the shortcut
0211                 // would technically make it unique.
0212                 areToolTipAndWhatsThisSimilar = isTextSimilar(action->whatsThis(), action->toolTip());
0213 
0214                 toolButton->setToolTip(i18nc("@info:tooltip %1 is the tooltip of an action, %2 is its keyboard shorcut",
0215                                              "%1 (%2)",
0216                                              action->toolTip(),
0217                                              action->shortcut().toString(QKeySequence::NativeText)));
0218                 // Do not replace the brackets in the above i18n-call with <shortcut> tags from
0219                 // KUIT because mixing KUIT with HTML is not allowed and %1 could be anything.
0220 
0221                 // We don't show the tooltip here because aside from adding the keyboard shortcut
0222                 // the QToolButton can now be handled like the tooltip event for any other widget.
0223             }
0224         }
0225     } else if (QMenu *menu = qobject_cast<QMenu *>(m_widget)) {
0226         return handleMenuToolTipEvent(menu, helpEvent);
0227     }
0228 
0229     while (m_widget->toolTip().isEmpty()) {
0230         m_widget = m_widget->parentWidget();
0231         if (!m_widget) {
0232             return false;
0233         }
0234     }
0235 
0236     if (m_widget->whatsThis().isEmpty() || areToolTipAndWhatsThisSimilar) {
0237         if (m_widget->toolTip() == whatsThisHintOnly()) {
0238             return true;
0239         }
0240         return false;
0241     }
0242     showExpandableToolTip(helpEvent->globalPos(), m_widget->toolTip());
0243     return true;
0244 }
0245 
0246 bool KToolTipHelperPrivate::handleWhatsThisClickedEvent(QEvent *event)
0247 {
0248     event->accept();
0249     const auto whatsThisClickedEvent = static_cast<QWhatsThisClickedEvent *>(event);
0250     QDesktopServices::openUrl(QUrl(whatsThisClickedEvent->href()));
0251     return true;
0252 }
0253 
0254 void KToolTipHelperPrivate::postToolTipEventIfCursorDidntMove() const
0255 {
0256     const QPoint globalCursorPos = QCursor::pos();
0257     if (globalCursorPos != m_cursorGlobalPosWhenLastMenuHid) {
0258         return;
0259     }
0260 
0261     const auto widgetUnderCursor = qApp->widgetAt(globalCursorPos);
0262     // We only want a behaviour change for QMenus.
0263     if (qobject_cast<QMenu *>(widgetUnderCursor)) {
0264         qGuiApp->postEvent(widgetUnderCursor, new QHelpEvent(QEvent::ToolTip, widgetUnderCursor->mapFromGlobal(globalCursorPos), globalCursorPos));
0265     }
0266 }
0267 
0268 void KToolTipHelperPrivate::showExpandableToolTip(const QPoint &globalPos, const QString &toolTip, const QRect &rect)
0269 {
0270     m_lastExpandableToolTipGlobalPos = QPoint(globalPos);
0271     m_lastToolTipWasExpandable = true;
0272     const KColorScheme colorScheme = KColorScheme(QPalette::Normal, KColorScheme::Tooltip);
0273     const QColor hintTextColor = colorScheme.foreground(KColorScheme::InactiveText).color();
0274 
0275     if (toolTip.isEmpty() || toolTip == whatsThisHintOnly()) {
0276         const QString whatsThisHint =
0277             // i18n: Pressing Shift will show a longer message with contextual info
0278             // about the thing the tooltip was invoked for. If there is no good way to translate
0279             // the message, translating "Press Shift to learn more." would also mostly fit what
0280             // is supposed to be expressed here.
0281             i18nc("@info:tooltip", "<small><font color=\"%1\">Press <b>Shift</b> for more Info.</font></small>", hintTextColor.name());
0282         QToolTip::showText(m_lastExpandableToolTipGlobalPos, whatsThisHint, m_widget, rect);
0283     } else {
0284         const QString toolTipWithHint = QStringLiteral("<qt>") +
0285             // i18n: The 'Press Shift for more' message is added to tooltips that have an
0286             // available whatsthis help message. Pressing Shift will show this more exhaustive message.
0287             // It is particularly important to keep this translation short because:
0288             // 1. A longer translation will increase the size of *every* tooltip that gets this hint
0289             //      added e.g. a two word tooltip followed by a four word hint.
0290             // 2. The purpose of this hint is so we can keep the tooltip shorter than it would have to
0291             //      be if we couldn't refer to the message that appears when pressing Shift.
0292             //
0293             // %1 can be any tooltip. <br/> produces a linebreak. The other things between < and > are
0294             // styling information. The word "more" refers to "information".
0295             i18nc("@info:tooltip keep short", "%1<br/><small><font color=\"%2\">Press <b>Shift</b> for more.</font></small>", toolTip, hintTextColor.name())
0296             + QStringLiteral("</qt>");
0297         // Do not replace above HTML tags with KUIT because mixing HTML and KUIT is not allowed and
0298         // we can not know what kind of markup the tooltip in %1 contains.
0299         QToolTip::showText(m_lastExpandableToolTipGlobalPos, toolTipWithHint, m_widget, rect);
0300     }
0301 }
0302 
0303 KToolTipHelper *KToolTipHelperPrivate::s_instance = nullptr;
0304 
0305 bool isTextSimilar(const QString &a, const QString &b)
0306 {
0307     int i = -1;
0308     int j = -1;
0309     do {
0310         i++;
0311         j++;
0312         // Both of these QStrings are considered equal if their only differences are '&' and '.' chars.
0313         // Now move both of their indices to the next char that is neither '&' nor '.'.
0314         while (i < a.size() && (a.at(i) == QLatin1Char('&') || a.at(i) == QLatin1Char('.'))) {
0315             i++;
0316         }
0317         while (j < b.size() && (b.at(j) == QLatin1Char('&') || b.at(j) == QLatin1Char('.'))) {
0318             j++;
0319         }
0320 
0321         if (i >= a.size()) {
0322             return j >= b.size();
0323         }
0324         if (j >= b.size()) {
0325             return i >= a.size();
0326         }
0327     } while (a.at(i) == b.at(j));
0328     return false; // We have found a difference.
0329 }
0330 
0331 #include "moc_ktooltiphelper.cpp"
0332 #include "moc_ktooltiphelper_p.cpp"