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