File indexing completed on 2024-04-14 03:53:47

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