File indexing completed on 2024-05-12 04:19:52

0001 // SPDX-FileCopyrightText: 2021 Noah Davis <noahadvs@gmail.com>
0002 // SPDX-License-Identifier: LGPL-2.1-or-later
0003 
0004 #include "zoomcombobox.h"
0005 #include "zoomcombobox_p.h"
0006 
0007 #include <KLocalizedString>
0008 #include <QAbstractItemView>
0009 #include <QAction>
0010 #include <QEvent>
0011 #include <QLineEdit>
0012 #include <QSignalBlocker>
0013 #include <QWheelEvent>
0014 
0015 #include <cmath>
0016 
0017 bool fuzzyEqual(qreal a, qreal b)
0018 {
0019     return (qFuzzyIsNull(a) && qFuzzyIsNull(b)) || qFuzzyCompare(a, b);
0020 }
0021 
0022 bool fuzzyLessEqual(qreal a, qreal b)
0023 {
0024     return fuzzyEqual(a, b) || a < b;
0025 }
0026 
0027 bool fuzzyGreaterEqual(qreal a, qreal b)
0028 {
0029     return fuzzyEqual(a, b) || a > b;
0030 }
0031 
0032 using namespace Gwenview;
0033 
0034 struct LineEditSelectionKeeper {
0035     LineEditSelectionKeeper(QLineEdit *lineEdit)
0036         : m_lineEdit(lineEdit)
0037     {
0038         Q_ASSERT(m_lineEdit);
0039         m_cursorPos = m_lineEdit->cursorPosition();
0040     }
0041 
0042     ~LineEditSelectionKeeper()
0043     {
0044         m_lineEdit->end(false);
0045         m_lineEdit->cursorBackward(true, m_lineEdit->text().length() - m_cursorPos);
0046     }
0047 
0048 private:
0049     QLineEdit *m_lineEdit;
0050     int m_cursorPos;
0051 };
0052 
0053 ZoomValidator::ZoomValidator(qreal minimum, qreal maximum, ZoomComboBox *q, ZoomComboBoxPrivate *d, QWidget *parent)
0054     : QValidator(parent)
0055     , m_minimum(minimum)
0056     , m_maximum(maximum)
0057     , m_zoomComboBox(q)
0058     , m_zoomComboBoxPrivate(d)
0059 {
0060 }
0061 
0062 ZoomValidator::~ZoomValidator() noexcept = default;
0063 
0064 qreal ZoomValidator::minimum() const
0065 {
0066     return m_minimum;
0067 }
0068 
0069 void ZoomValidator::setMinimum(const qreal minimum)
0070 {
0071     if (fuzzyEqual(m_minimum, minimum)) {
0072         return;
0073     }
0074     m_minimum = minimum;
0075     Q_EMIT changed();
0076 }
0077 
0078 qreal ZoomValidator::maximum() const
0079 {
0080     return m_maximum;
0081 }
0082 
0083 void ZoomValidator::setMaximum(const qreal maximum)
0084 {
0085     if (fuzzyEqual(m_maximum, maximum)) {
0086         return;
0087     }
0088     m_maximum = maximum;
0089     Q_EMIT changed();
0090 }
0091 
0092 QValidator::State ZoomValidator::validate(QString &input, int &pos) const
0093 {
0094     Q_UNUSED(pos)
0095     if (m_zoomComboBox->findText(input, Qt::MatchFixedString) > -1) {
0096         return QValidator::Acceptable;
0097     }
0098 
0099     QString copy = input.trimmed();
0100     copy.remove(locale().groupSeparator());
0101     copy.remove(locale().percent());
0102     const bool startsWithNumber = copy.constBegin()->isNumber();
0103 
0104     if (startsWithNumber || copy.isEmpty()) {
0105         return QValidator::Intermediate;
0106     }
0107 
0108     QValidator::State state;
0109     bool ok = false;
0110     int value = locale().toInt(copy, &ok);
0111     if (!ok || value < std::ceil(m_minimum * 100) || value > std::floor(m_maximum * 100)) {
0112         state = QValidator::Intermediate;
0113     } else {
0114         state = QValidator::Acceptable;
0115     }
0116     return state;
0117 }
0118 
0119 ZoomComboBoxPrivate::ZoomComboBoxPrivate(ZoomComboBox *q)
0120     : q_ptr(q)
0121     , validator(new ZoomValidator(0, 0, q, this, q))
0122 {
0123 }
0124 
0125 ZoomComboBox::ZoomComboBox(QWidget *parent)
0126     : QComboBox(parent)
0127     , d_ptr(new ZoomComboBoxPrivate(this))
0128 {
0129     Q_D(ZoomComboBox);
0130 
0131     d->validator->setObjectName(QLatin1String("zoomValidator"));
0132     setValidator(d->validator);
0133 
0134     setEditable(true);
0135     setInsertPolicy(QComboBox::NoInsert);
0136     // QLocale::percent() will return a QString in Qt 6.
0137     // Qt encourages using QString(locale().percent()) in QLocale documentation.
0138     const int percentLength = QString(locale().percent()).length();
0139     setMinimumContentsLength(locale().toString(9999).length() + percentLength);
0140 
0141     connect(lineEdit(), &QLineEdit::textEdited, this, [this, d](const QString &text) {
0142         const bool startsWithNumber = text.constBegin()->isNumber();
0143         int matchedIndex = -1;
0144         if (startsWithNumber) {
0145             matchedIndex = findText(text, Qt::MatchFixedString);
0146         } else {
0147             // check if there is more than 1 match
0148             for (int i = 0, n = count(); i < n; ++i) {
0149                 if (itemText(i).startsWith(text, Qt::CaseInsensitive)) {
0150                     if (matchedIndex != -1) {
0151                         // there is more than 1 match
0152                         return;
0153                     }
0154                     matchedIndex = i;
0155                 }
0156             }
0157         }
0158         if (matchedIndex != -1) {
0159             LineEditSelectionKeeper selectionKeeper(lineEdit());
0160             updateCurrentIndex();
0161             if (matchedIndex == currentIndex()) {
0162                 updateDisplayedText();
0163             } else {
0164                 activateAndChangeZoomTo(matchedIndex);
0165             }
0166         } else if (startsWithNumber) {
0167             bool ok = false;
0168             qreal value = valueFromText(text, &ok);
0169             if (ok && value > 0 && fuzzyLessEqual(value, maximum())) {
0170                 // emulate autocompletion for valid values that aren't predefined
0171                 while (value < minimum()) {
0172                     value *= 10;
0173                     if (value > maximum()) {
0174                         // autocompletion cannot be emulated for this value
0175                         return;
0176                     }
0177                 }
0178                 LineEditSelectionKeeper selectionKeeper(lineEdit());
0179                 if (fuzzyEqual(value, d->value)) {
0180                     updateDisplayedText();
0181                 } else {
0182                     d->lastCustomZoomValue = value;
0183                     activateAndChangeZoomTo(-1);
0184                 }
0185             }
0186         }
0187     });
0188     connect(this, qOverload<int>(&ZoomComboBox::highlighted), this, &ZoomComboBox::changeZoomTo);
0189     view()->installEventFilter(this);
0190     connect(this, qOverload<int>(&ZoomComboBox::activated), this, &ZoomComboBox::activateAndChangeZoomTo);
0191 }
0192 
0193 ZoomComboBox::~ZoomComboBox() noexcept = default;
0194 
0195 void ZoomComboBox::setActions(QAction *zoomToFitAction, QAction *zoomToFillAction, QAction *actualSizeAction)
0196 {
0197     Q_D(ZoomComboBox);
0198     d->setActions(zoomToFitAction, zoomToFillAction, actualSizeAction);
0199 
0200     connect(zoomToFitAction, &QAction::toggled, this, &ZoomComboBox::updateDisplayedText);
0201     connect(zoomToFillAction, &QAction::toggled, this, &ZoomComboBox::updateDisplayedText);
0202 }
0203 
0204 void ZoomComboBoxPrivate::setActions(QAction *zoomToFitAction, QAction *zoomToFillAction, QAction *actualSizeAction)
0205 {
0206     Q_Q(ZoomComboBox);
0207     q->clear();
0208     q->addItem(zoomToFitAction->iconText(), QVariant::fromValue(zoomToFitAction)); // index = 0
0209     q->addItem(zoomToFillAction->iconText(), QVariant::fromValue(zoomToFillAction)); // index = 1
0210     q->addItem(actualSizeAction->iconText(), QVariant::fromValue(actualSizeAction)); // index will change later
0211 
0212     mZoomToFitAction = zoomToFitAction;
0213     mZoomToFillAction = zoomToFillAction;
0214     mActualSizeAction = actualSizeAction;
0215 }
0216 
0217 qreal ZoomComboBox::value() const
0218 {
0219     Q_D(const ZoomComboBox);
0220     return d->value;
0221 }
0222 
0223 void ZoomComboBox::setValue(qreal value)
0224 {
0225     Q_D(ZoomComboBox);
0226     d->value = value;
0227 
0228     updateDisplayedText();
0229 }
0230 
0231 qreal ZoomComboBox::minimum() const
0232 {
0233     Q_D(const ZoomComboBox);
0234     return d->validator->minimum();
0235 }
0236 
0237 void ZoomComboBox::setMinimum(qreal minimum)
0238 {
0239     Q_D(ZoomComboBox);
0240     if (fuzzyEqual(this->minimum(), minimum)) {
0241         return;
0242     }
0243     d->validator->setMinimum(minimum);
0244     setValue(qMax(minimum, d->value));
0245     // Generate zoom presets below 100%
0246     // FIXME: combobox value gets reset to last index value when this code runs
0247     const int zoomToFillActionIndex = findData(QVariant::fromValue(d->mZoomToFillAction));
0248     const int actualSizeActionIndex = findData(QVariant::fromValue(d->mActualSizeAction));
0249     for (int i = actualSizeActionIndex - 1; i > zoomToFillActionIndex; --i) {
0250         removeItem(i);
0251     }
0252     qreal value = minimum * 2; // The minimum zoom value itself is already available through "fit".
0253     for (int i = zoomToFillActionIndex + 1; value < 1.0; ++i) {
0254         insertItem(i, textFromValue(value), QVariant::fromValue(value));
0255         value *= 2;
0256     }
0257 }
0258 
0259 qreal ZoomComboBox::maximum() const
0260 {
0261     Q_D(const ZoomComboBox);
0262     return d->validator->maximum();
0263 }
0264 
0265 void ZoomComboBox::setMaximum(qreal maximum)
0266 {
0267     Q_D(ZoomComboBox);
0268     if (fuzzyEqual(this->maximum(), maximum)) {
0269         return;
0270     }
0271     d->validator->setMaximum(maximum);
0272     setValue(qMin(d->value, maximum));
0273     // Generate zoom presets above 100%
0274     // NOTE: This probably has the same problem as setMinimum(),
0275     // but the problem is never enountered since max zoom doesn't actually change
0276     const int actualSizeActionIndex = findData(QVariant::fromValue(d->mActualSizeAction));
0277     const int count = this->count();
0278     for (int i = actualSizeActionIndex + 1; i < count; ++i) {
0279         removeItem(i);
0280     }
0281     qreal value = 2.0;
0282     while (value < maximum) {
0283         addItem(textFromValue(value), QVariant::fromValue(value));
0284         value *= 2;
0285     }
0286     if (fuzzyGreaterEqual(value, maximum)) {
0287         addItem(textFromValue(maximum), QVariant::fromValue(maximum));
0288     }
0289 }
0290 
0291 qreal ZoomComboBox::valueFromText(const QString &text, bool *ok) const
0292 {
0293     Q_D(const ZoomComboBox);
0294 
0295     const QLocale l = locale();
0296     QString s = text;
0297     s.remove(l.groupSeparator());
0298 
0299     if (s.endsWith(l.percent())) {
0300         s = s.chopped(1);
0301     }
0302 
0303     if (s.startsWith(l.percent())) {
0304         s = s.remove(0, 1);
0305     }
0306 
0307     return l.toDouble(s, ok) / 100.0;
0308 }
0309 
0310 QString ZoomComboBox::textFromValue(const qreal value) const
0311 {
0312     Q_D(const ZoomComboBox);
0313 
0314     QLocale l = locale();
0315     l.setNumberOptions(QLocale::OmitGroupSeparator);
0316 
0317     QString formattedValue = l.toString(qRound(value * 100));
0318     return i18nc("Percent value", "%1%", formattedValue);
0319 }
0320 
0321 void ZoomComboBox::updateDisplayedText()
0322 {
0323     Q_D(ZoomComboBox);
0324     if (d->mZoomToFitAction->isChecked()) {
0325         lineEdit()->setText(d->mZoomToFitAction->iconText());
0326     } else if (d->mZoomToFillAction->isChecked()) {
0327         lineEdit()->setText(d->mZoomToFillAction->iconText());
0328     } else if (d->mActualSizeAction->isChecked()) {
0329         lineEdit()->setText(d->mActualSizeAction->iconText());
0330     } else {
0331         lineEdit()->setText(textFromValue(d->value));
0332     }
0333 }
0334 
0335 void Gwenview::ZoomComboBox::showPopup()
0336 {
0337     updateCurrentIndex();
0338 
0339     // We don't want to emit a QComboBox::highlighted event just because the popup is opened.
0340     const QSignalBlocker blocker(this);
0341     QComboBox::showPopup();
0342 }
0343 
0344 bool ZoomComboBox::eventFilter(QObject *watched, QEvent *event)
0345 {
0346     if (watched == view()) {
0347         switch (event->type()) {
0348         case QEvent::Hide: {
0349             Q_D(ZoomComboBox);
0350             changeZoomTo(d->lastSelectedIndex);
0351             break;
0352         }
0353         case QEvent::ShortcutOverride: {
0354             if (view()->isVisibleTo(this)) {
0355                 auto keyEvent = static_cast<QKeyEvent *>(event);
0356                 if (keyEvent->key() == Qt::Key_Escape) {
0357                     event->accept();
0358                 }
0359             }
0360             break;
0361         }
0362         default:
0363             break;
0364         }
0365     }
0366 
0367     return QComboBox::eventFilter(watched, event);
0368 }
0369 
0370 void ZoomComboBox::focusOutEvent(QFocusEvent *event)
0371 {
0372     Q_D(ZoomComboBox);
0373     // Should the user have started typing a custom value
0374     // that was out of our range, then we have a temporary
0375     // state that is illegal. This is needed to allow a user
0376     // to type a zoom with multiple keystrokes, but when the
0377     // user leaves focus we should reset to the last known 'good'
0378     // zoom value.
0379     if (d->lastSelectedIndex == -1)
0380         setValue(d->lastCustomZoomValue);
0381 
0382     QComboBox::focusOutEvent(event);
0383 }
0384 
0385 void ZoomComboBox::keyPressEvent(QKeyEvent *event)
0386 {
0387     switch (event->key()) {
0388     case Qt::Key_Down:
0389     case Qt::Key_Up:
0390     case Qt::Key_PageDown:
0391     case Qt::Key_PageUp: {
0392         updateCurrentIndex();
0393         if (currentIndex() != -1) {
0394             break;
0395         }
0396         moveCurrentIndex(event->key() == Qt::Key_Down || event->key() == Qt::Key_PageDown);
0397         return;
0398     }
0399     default:
0400         break;
0401     }
0402 
0403     QComboBox::keyPressEvent(event);
0404 }
0405 
0406 void ZoomComboBox::wheelEvent(QWheelEvent *event)
0407 {
0408     updateCurrentIndex();
0409     if (currentIndex() != -1) {
0410         // Everything should work as expected.
0411         QComboBox::wheelEvent(event);
0412         return;
0413     }
0414 
0415     moveCurrentIndex(event->angleDelta().y() < 0);
0416 }
0417 
0418 void ZoomComboBox::updateCurrentIndex()
0419 {
0420     Q_D(ZoomComboBox);
0421 
0422     if (d->mZoomToFitAction->isChecked()) {
0423         setCurrentIndex(0);
0424         d->lastSelectedIndex = 0;
0425     } else if (d->mZoomToFillAction->isChecked()) {
0426         setCurrentIndex(1);
0427         d->lastSelectedIndex = 1;
0428     } else if (d->mActualSizeAction->isChecked()) {
0429         const int actualSizeActionIndex = findData(QVariant::fromValue(d->mActualSizeAction));
0430         setCurrentIndex(actualSizeActionIndex);
0431         d->lastSelectedIndex = actualSizeActionIndex;
0432     } else {
0433         // Now is a good time to save the zoom value that was selected before the user changes it through the popup.
0434         d->lastCustomZoomValue = d->value;
0435 
0436         // Highlight the correct index if the current zoom value exists as an option in the popup.
0437         // If it doesn't exist, it is set to -1.
0438         d->lastSelectedIndex = findText(textFromValue(d->value));
0439         setCurrentIndex(d->lastSelectedIndex);
0440     }
0441 }
0442 
0443 void ZoomComboBox::moveCurrentIndex(bool moveUp)
0444 {
0445     // There is no exact match for the current zoom value in the
0446     // ComboBox. We need to find the closest matches, so scrolling
0447     // works as expected.
0448     Q_D(ZoomComboBox);
0449 
0450     int newIndex;
0451     if (moveUp) {
0452         // switch to a larger item
0453         newIndex = count() - 1;
0454         for (int i = 2; i < newIndex; ++i) {
0455             const QVariant data = itemData(i);
0456             qreal value;
0457             if (data.value<QAction *>() == d->mActualSizeAction) {
0458                 value = 1;
0459             } else {
0460                 value = data.value<qreal>();
0461             }
0462             if (value > d->value) {
0463                 newIndex = i;
0464                 break;
0465             }
0466         }
0467     } else {
0468         // switch to a smaller item
0469         newIndex = 1;
0470         for (int i = count() - 1; i > newIndex; --i) {
0471             const QVariant data = itemData(i);
0472             qreal value;
0473             if (data.value<QAction *>() == d->mActualSizeAction) {
0474                 value = 1;
0475             } else {
0476                 value = data.value<qreal>();
0477             }
0478             if (value < d->value) {
0479                 newIndex = i;
0480                 break;
0481             }
0482         }
0483     }
0484     setCurrentIndex(newIndex);
0485     Q_EMIT activated(newIndex);
0486 }
0487 
0488 void ZoomComboBox::changeZoomTo(int index)
0489 {
0490     if (index < 0) {
0491         Q_D(ZoomComboBox);
0492         Q_EMIT zoomChanged(d->lastCustomZoomValue);
0493         return;
0494     }
0495 
0496     QVariant itemData = this->itemData(index);
0497     auto action = itemData.value<QAction *>();
0498     if (action) {
0499         if (!action->isCheckable() || !action->isChecked()) {
0500             action->trigger();
0501         }
0502     } else if (itemData.canConvert(QMetaType::QReal)) {
0503         Q_EMIT zoomChanged(itemData.toReal());
0504     }
0505 }
0506 
0507 void ZoomComboBox::activateAndChangeZoomTo(int index)
0508 {
0509     Q_D(ZoomComboBox);
0510     d->lastSelectedIndex = index;
0511 
0512     // The user has explicitly selected this zoom value so we
0513     // remember it the same way as if they had typed it themselves.
0514     QVariant itemData = this->itemData(index);
0515     if (!itemData.value<QAction *>() && itemData.canConvert(QMetaType::QReal)) {
0516         d->lastCustomZoomValue = itemData.toReal();
0517     }
0518 
0519     changeZoomTo(index);
0520 }
0521 
0522 #include "moc_zoomcombobox.cpp"
0523 
0524 #include "moc_zoomcombobox_p.cpp"