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"