File indexing completed on 2024-09-15 12:04:29
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, QStringLiteral(), 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"