File indexing completed on 2024-04-28 15:27:43

0001 /*
0002  *  SPDX-FileCopyrightText: 2017 Marco Martin <mart@kde.org>
0003  *
0004  *  SPDX-License-Identifier: LGPL-2.0-or-later
0005  */
0006 
0007 #include "mnemonicattached.h"
0008 #include <QDebug>
0009 #include <QQuickItem>
0010 #include <QQuickRenderControl>
0011 #include <QRegularExpression>
0012 
0013 QHash<QKeySequence, MnemonicAttached *> MnemonicAttached::s_sequenceToObject = QHash<QKeySequence, MnemonicAttached *>();
0014 
0015 // If pos points to alphanumeric X in "...(X)...", which is preceded or
0016 // followed only by non-alphanumerics, then "(X)" gets removed.
0017 static QString removeReducedCJKAccMark(const QString &label, int pos)
0018 {
0019     /* clang-format off */
0020     if (pos > 0 && pos + 1 < label.length()
0021         && label[pos - 1] == QLatin1Char('(')
0022         && label[pos + 1] == QLatin1Char(')')
0023         && label[pos].isLetterOrNumber()) { /* clang-format on */
0024         // Check if at start or end, ignoring non-alphanumerics.
0025         int len = label.length();
0026         int p1 = pos - 2;
0027         while (p1 >= 0 && !label[p1].isLetterOrNumber()) {
0028             --p1;
0029         }
0030         ++p1;
0031         int p2 = pos + 2;
0032         while (p2 < len && !label[p2].isLetterOrNumber()) {
0033             ++p2;
0034         }
0035         --p2;
0036 
0037         const QStringView strView(label);
0038         if (p1 == 0) {
0039             return strView.left(pos - 1) + strView.mid(p2 + 1);
0040         } else if (p2 + 1 == len) {
0041             return strView.left(p1) + strView.mid(pos + 2);
0042         }
0043     }
0044     return label;
0045 }
0046 
0047 static QString removeAcceleratorMarker(const QString &label_)
0048 {
0049     QString label = label_;
0050 
0051     int p = 0;
0052     bool accmarkRemoved = false;
0053     while (true) {
0054         p = label.indexOf(QLatin1Char('&'), p);
0055         if (p < 0 || p + 1 == label.length()) {
0056             break;
0057         }
0058 
0059         if (label.at(p + 1).isLetterOrNumber()) {
0060             // Valid accelerator.
0061             const QStringView sv(label);
0062             label = sv.left(p) + sv.mid(p + 1);
0063 
0064             // May have been an accelerator in CJK-style "(&X)"
0065             // at the start or end of text.
0066             label = removeReducedCJKAccMark(label, p);
0067 
0068             accmarkRemoved = true;
0069         } else if (label.at(p + 1) == QLatin1Char('&')) {
0070             // Escaped accelerator marker.
0071             const QStringView sv(label);
0072             label = sv.left(p) + sv.mid(p + 1);
0073         }
0074 
0075         ++p;
0076     }
0077 
0078     // If no marker was removed, and there are CJK characters in the label,
0079     // also try to remove reduced CJK marker -- something may have removed
0080     // ampersand beforehand.
0081     if (!accmarkRemoved) {
0082         bool hasCJK = false;
0083         for (const QChar c : std::as_const(label)) {
0084             if (c.unicode() >= 0x2e00) { // rough, but should be sufficient
0085                 hasCJK = true;
0086                 break;
0087             }
0088         }
0089         if (hasCJK) {
0090             p = 0;
0091             while (true) {
0092                 p = label.indexOf(QLatin1Char('('), p);
0093                 if (p < 0) {
0094                     break;
0095                 }
0096                 label = removeReducedCJKAccMark(label, p + 1);
0097                 ++p;
0098             }
0099         }
0100     }
0101 
0102     return label;
0103 }
0104 
0105 MnemonicAttached::MnemonicAttached(QObject *parent)
0106     : QObject(parent)
0107 {
0108     QQuickItem *parentItem = qobject_cast<QQuickItem *>(parent);
0109     if (parentItem) {
0110         if (parentItem->window()) {
0111             m_window = parentItem->window();
0112             m_window->installEventFilter(this);
0113         }
0114         connect(parentItem, &QQuickItem::windowChanged, this, [this](QQuickWindow *window) {
0115             removeEventFilterForWindow(m_window);
0116             m_window = window;
0117             installEventFilterForWindow(m_window);
0118         });
0119     }
0120 }
0121 
0122 MnemonicAttached::~MnemonicAttached()
0123 {
0124     s_sequenceToObject.remove(m_sequence);
0125 }
0126 
0127 bool MnemonicAttached::eventFilter(QObject *watched, QEvent *e)
0128 {
0129     Q_UNUSED(watched)
0130 
0131     if (m_richTextLabel.isEmpty()) {
0132         return false;
0133     }
0134 
0135     if (e->type() == QEvent::KeyPress) {
0136         QKeyEvent *ke = static_cast<QKeyEvent *>(e);
0137         if (ke->key() == Qt::Key_Alt) {
0138             m_actualRichTextLabel = m_richTextLabel;
0139             Q_EMIT richTextLabelChanged();
0140             m_active = true;
0141             Q_EMIT activeChanged();
0142         }
0143 
0144     } else if (e->type() == QEvent::KeyRelease) {
0145         QKeyEvent *ke = static_cast<QKeyEvent *>(e);
0146         if (ke->key() == Qt::Key_Alt) {
0147             m_actualRichTextLabel = removeAcceleratorMarker(m_label);
0148             Q_EMIT richTextLabelChanged();
0149             m_active = false;
0150             Q_EMIT activeChanged();
0151         }
0152     }
0153     return false;
0154 }
0155 
0156 // Algorithm adapted from KAccelString
0157 void MnemonicAttached::calculateWeights()
0158 {
0159     m_weights.clear();
0160 
0161     int pos = 0;
0162     bool start_character = true;
0163     bool wanted_character = false;
0164 
0165     while (pos < m_label.length()) {
0166         QChar c = m_label[pos];
0167 
0168         // skip non typeable characters
0169         if (!c.isLetterOrNumber() && c != QLatin1Char('&')) {
0170             start_character = true;
0171             ++pos;
0172             continue;
0173         }
0174 
0175         int weight = 1;
0176 
0177         // add special weight to first character
0178         if (pos == 0) {
0179             weight += FIRST_CHARACTER_EXTRA_WEIGHT;
0180             // add weight to word beginnings
0181         } else if (start_character) {
0182             weight += WORD_BEGINNING_EXTRA_WEIGHT;
0183             start_character = false;
0184         }
0185 
0186         // add weight to characters that have an & beforehand
0187         if (wanted_character) {
0188             weight += WANTED_ACCEL_EXTRA_WEIGHT;
0189             wanted_character = false;
0190         }
0191 
0192         // add decreasing weight to left characters
0193         if (pos < 50) {
0194             weight += (50 - pos);
0195         }
0196 
0197         // try to preserve the wanted accelerators
0198         /* clang-format off */
0199         if (c == QLatin1Char('&')
0200             && (pos != m_label.length() - 1
0201                 && m_label[pos + 1] != QLatin1Char('&')
0202                 && m_label[pos + 1].isLetterOrNumber())) { /* clang-format on */
0203             wanted_character = true;
0204             ++pos;
0205             continue;
0206         }
0207 
0208         while (m_weights.contains(weight)) {
0209             ++weight;
0210         }
0211 
0212         if (c != QLatin1Char('&')) {
0213             m_weights[weight] = c;
0214         }
0215 
0216         ++pos;
0217     }
0218 
0219     // update our maximum weight
0220     if (m_weights.isEmpty()) {
0221         m_weight = m_baseWeight;
0222     } else {
0223         m_weight = m_baseWeight + (m_weights.cend() - 1).key();
0224     }
0225 }
0226 
0227 bool MnemonicAttached::installEventFilterForWindow(QQuickWindow *wnd)
0228 {
0229     if (!wnd) {
0230         return false;
0231     }
0232     QWindow *renderWindow = QQuickRenderControl::renderWindowFor(wnd);
0233     // renderWindow means the widget is rendering somewhere else, like a QQuickWidget
0234     if (renderWindow && renderWindow != m_window) {
0235         renderWindow->installEventFilter(this);
0236     } else {
0237         wnd->installEventFilter(this);
0238     }
0239     return true;
0240 }
0241 
0242 bool MnemonicAttached::removeEventFilterForWindow(QQuickWindow *wnd)
0243 {
0244     if (!wnd) {
0245         return false;
0246     }
0247     QWindow *renderWindow = QQuickRenderControl::renderWindowFor(wnd);
0248     if (renderWindow) {
0249         renderWindow->removeEventFilter(this);
0250     } else {
0251         wnd->removeEventFilter(this);
0252     }
0253     return true;
0254 }
0255 
0256 void MnemonicAttached::updateSequence()
0257 {
0258     if (!m_sequence.isEmpty()) {
0259         s_sequenceToObject.remove(m_sequence);
0260         m_sequence = {};
0261     }
0262 
0263     calculateWeights();
0264 
0265     // Preserve strings like "One & Two" where & is not an accelerator escape
0266     const QString text = label().replace(QStringLiteral("& "), QStringLiteral("&& "));
0267 
0268     if (!m_enabled) {
0269         m_actualRichTextLabel = removeAcceleratorMarker(text);
0270         // was the label already completely plain text? try to limit signal emission
0271         if (m_mnemonicLabel != m_actualRichTextLabel) {
0272             m_mnemonicLabel = m_actualRichTextLabel;
0273             Q_EMIT mnemonicLabelChanged();
0274             Q_EMIT richTextLabelChanged();
0275         }
0276         return;
0277     }
0278 
0279     if (!m_weights.isEmpty()) {
0280         QMap<int, QChar>::const_iterator i = m_weights.constEnd();
0281         do {
0282             --i;
0283             QChar c = i.value();
0284 
0285             QKeySequence ks(QStringLiteral("Alt+") % c);
0286             MnemonicAttached *otherMa = s_sequenceToObject.value(ks);
0287             Q_ASSERT(otherMa != this);
0288             if (!otherMa || otherMa->m_weight < m_weight) {
0289                 // the old shortcut is less valuable than the current: remove it
0290                 if (otherMa) {
0291                     s_sequenceToObject.remove(otherMa->sequence());
0292                     otherMa->m_sequence = {};
0293                 }
0294 
0295                 s_sequenceToObject[ks] = this;
0296                 m_sequence = ks;
0297                 m_richTextLabel = text;
0298                 m_richTextLabel.replace(QRegularExpression(QLatin1String("\\&([^\\&])")), QStringLiteral("\\1"));
0299                 m_actualRichTextLabel = m_richTextLabel;
0300                 m_mnemonicLabel = text;
0301                 const int mnemonicPos = m_mnemonicLabel.indexOf(c);
0302 
0303                 if (mnemonicPos > -1 && (mnemonicPos == 0 || m_mnemonicLabel[mnemonicPos - 1] != QLatin1Char('&'))) {
0304                     m_mnemonicLabel.replace(mnemonicPos, 1, QStringLiteral("&") % c);
0305                 }
0306 
0307                 const int richTextPos = m_richTextLabel.indexOf(c);
0308                 if (richTextPos > -1) {
0309                     m_richTextLabel.replace(richTextPos, 1, QLatin1String("<u>") % c % QLatin1String("</u>"));
0310                 }
0311 
0312                 // remap the sequence of the previous shortcut
0313                 if (otherMa) {
0314                     otherMa->updateSequence();
0315                 }
0316 
0317                 break;
0318             }
0319         } while (i != m_weights.constBegin());
0320     }
0321 
0322     if (!m_sequence.isEmpty()) {
0323         Q_EMIT sequenceChanged();
0324     }
0325     m_actualRichTextLabel = removeAcceleratorMarker(text);
0326     m_mnemonicLabel = m_actualRichTextLabel;
0327 
0328     Q_EMIT richTextLabelChanged();
0329     Q_EMIT mnemonicLabelChanged();
0330 }
0331 
0332 void MnemonicAttached::setLabel(const QString &text)
0333 {
0334     if (m_label == text) {
0335         return;
0336     }
0337 
0338     m_label = text;
0339     updateSequence();
0340     Q_EMIT labelChanged();
0341 }
0342 
0343 QString MnemonicAttached::richTextLabel() const
0344 {
0345     if (!m_actualRichTextLabel.isEmpty()) {
0346         return m_actualRichTextLabel;
0347     } else {
0348         return removeAcceleratorMarker(m_label);
0349     }
0350 }
0351 
0352 QString MnemonicAttached::mnemonicLabel() const
0353 {
0354     return m_mnemonicLabel;
0355 }
0356 
0357 QString MnemonicAttached::label() const
0358 {
0359     return m_label;
0360 }
0361 
0362 void MnemonicAttached::setEnabled(bool enabled)
0363 {
0364     if (m_enabled == enabled) {
0365         return;
0366     }
0367 
0368     m_enabled = enabled;
0369     updateSequence();
0370     Q_EMIT enabledChanged();
0371 }
0372 
0373 bool MnemonicAttached::enabled() const
0374 {
0375     return m_enabled;
0376 }
0377 
0378 void MnemonicAttached::setControlType(MnemonicAttached::ControlType controlType)
0379 {
0380     if (m_controlType == controlType) {
0381         return;
0382     }
0383 
0384     m_controlType = controlType;
0385 
0386     switch (controlType) {
0387     case ActionElement:
0388         m_baseWeight = ACTION_ELEMENT_WEIGHT;
0389         break;
0390     case DialogButton:
0391         m_baseWeight = DIALOG_BUTTON_EXTRA_WEIGHT;
0392         break;
0393     case MenuItem:
0394         m_baseWeight = MENU_ITEM_WEIGHT;
0395         break;
0396     case FormLabel:
0397         m_baseWeight = FORM_LABEL_WEIGHT;
0398         break;
0399     default:
0400         m_baseWeight = SECONDARY_CONTROL_WEIGHT;
0401         break;
0402     }
0403     // update our maximum weight
0404     if (m_weights.isEmpty()) {
0405         m_weight = m_baseWeight;
0406     } else {
0407         m_weight = m_baseWeight + (m_weights.constEnd() - 1).key();
0408     }
0409     Q_EMIT controlTypeChanged();
0410 }
0411 
0412 MnemonicAttached::ControlType MnemonicAttached::controlType() const
0413 {
0414     return m_controlType;
0415 }
0416 
0417 QKeySequence MnemonicAttached::sequence()
0418 {
0419     return m_sequence;
0420 }
0421 
0422 bool MnemonicAttached::active() const
0423 {
0424     return m_active;
0425 }
0426 
0427 MnemonicAttached *MnemonicAttached::qmlAttachedProperties(QObject *object)
0428 {
0429     return new MnemonicAttached(object);
0430 }
0431 
0432 void MnemonicAttached::setActive(bool active)
0433 {
0434     // We can't rely on previous value when it's true since it can be
0435     // caused by Alt key press and we need to remove the event filter
0436     // additionally. False should be ok as it's a default state.
0437     if (!m_active && m_active == active) {
0438         return;
0439     }
0440 
0441     m_active = active;
0442 
0443     if (m_active) {
0444         removeEventFilterForWindow(m_window);
0445         if (m_actualRichTextLabel != m_richTextLabel) {
0446             m_actualRichTextLabel = m_richTextLabel;
0447             Q_EMIT richTextLabelChanged();
0448         }
0449 
0450     } else {
0451         installEventFilterForWindow(m_window);
0452         m_actualRichTextLabel = removeAcceleratorMarker(m_label);
0453         Q_EMIT richTextLabelChanged();
0454     }
0455 
0456     Q_EMIT activeChanged();
0457 }
0458 
0459 #include "moc_mnemonicattached.cpp"