525 lines
15 KiB
C++
Raw Normal View History

2024-06-29 11:52:32 +06:00
// SPDX-FileCopyrightText: 2021 Noah Davis <noahadvs@gmail.com>
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "zoomcombobox.h"
#include "zoomcombobox_p.h"
#include <KLocalizedString>
#include <QAbstractItemView>
#include <QAction>
#include <QEvent>
#include <QLineEdit>
#include <QSignalBlocker>
#include <QWheelEvent>
#include <cmath>
bool fuzzyEqual(qreal a, qreal b)
{
return (qFuzzyIsNull(a) && qFuzzyIsNull(b)) || qFuzzyCompare(a, b);
}
bool fuzzyLessEqual(qreal a, qreal b)
{
return fuzzyEqual(a, b) || a < b;
}
bool fuzzyGreaterEqual(qreal a, qreal b)
{
return fuzzyEqual(a, b) || a > b;
}
using namespace Gwenview;
struct LineEditSelectionKeeper {
LineEditSelectionKeeper(QLineEdit *lineEdit)
: m_lineEdit(lineEdit)
{
Q_ASSERT(m_lineEdit);
m_cursorPos = m_lineEdit->cursorPosition();
}
~LineEditSelectionKeeper()
{
m_lineEdit->end(false);
m_lineEdit->cursorBackward(true, m_lineEdit->text().length() - m_cursorPos);
}
private:
QLineEdit *m_lineEdit;
int m_cursorPos;
};
ZoomValidator::ZoomValidator(qreal minimum, qreal maximum, ZoomComboBox *q, ZoomComboBoxPrivate *d, QWidget *parent)
: QValidator(parent)
, m_minimum(minimum)
, m_maximum(maximum)
, m_zoomComboBox(q)
, m_zoomComboBoxPrivate(d)
{
}
ZoomValidator::~ZoomValidator() noexcept = default;
qreal ZoomValidator::minimum() const
{
return m_minimum;
}
void ZoomValidator::setMinimum(const qreal minimum)
{
if (fuzzyEqual(m_minimum, minimum)) {
return;
}
m_minimum = minimum;
Q_EMIT changed();
}
qreal ZoomValidator::maximum() const
{
return m_maximum;
}
void ZoomValidator::setMaximum(const qreal maximum)
{
if (fuzzyEqual(m_maximum, maximum)) {
return;
}
m_maximum = maximum;
Q_EMIT changed();
}
QValidator::State ZoomValidator::validate(QString &input, int &pos) const
{
Q_UNUSED(pos)
if (m_zoomComboBox->findText(input, Qt::MatchFixedString) > -1) {
return QValidator::Acceptable;
}
QString copy = input.trimmed();
copy.remove(locale().groupSeparator());
copy.remove(locale().percent());
const bool startsWithNumber = copy.constBegin()->isNumber();
if (startsWithNumber || copy.isEmpty()) {
return QValidator::Intermediate;
}
QValidator::State state;
bool ok = false;
int value = locale().toInt(copy, &ok);
if (!ok || value < std::ceil(m_minimum * 100) || value > std::floor(m_maximum * 100)) {
state = QValidator::Intermediate;
} else {
state = QValidator::Acceptable;
}
return state;
}
ZoomComboBoxPrivate::ZoomComboBoxPrivate(ZoomComboBox *q)
: q_ptr(q)
, validator(new ZoomValidator(0, 0, q, this, q))
{
}
ZoomComboBox::ZoomComboBox(QWidget *parent)
: QComboBox(parent)
, d_ptr(new ZoomComboBoxPrivate(this))
{
Q_D(ZoomComboBox);
d->validator->setObjectName(QLatin1String("zoomValidator"));
setValidator(d->validator);
setEditable(true);
setInsertPolicy(QComboBox::NoInsert);
// QLocale::percent() will return a QString in Qt 6.
// Qt encourages using QString(locale().percent()) in QLocale documentation.
const int percentLength = QString(locale().percent()).length();
setMinimumContentsLength(locale().toString(9999).length() + percentLength);
connect(lineEdit(), &QLineEdit::textEdited, this, [this, d](const QString &text) {
const bool startsWithNumber = text.constBegin()->isNumber();
int matchedIndex = -1;
if (startsWithNumber) {
matchedIndex = findText(text, Qt::MatchFixedString);
} else {
// check if there is more than 1 match
for (int i = 0, n = count(); i < n; ++i) {
if (itemText(i).startsWith(text, Qt::CaseInsensitive)) {
if (matchedIndex != -1) {
// there is more than 1 match
return;
}
matchedIndex = i;
}
}
}
if (matchedIndex != -1) {
LineEditSelectionKeeper selectionKeeper(lineEdit());
updateCurrentIndex();
if (matchedIndex == currentIndex()) {
updateDisplayedText();
} else {
activateAndChangeZoomTo(matchedIndex);
}
} else if (startsWithNumber) {
bool ok = false;
qreal value = valueFromText(text, &ok);
if (ok && value > 0 && fuzzyLessEqual(value, maximum())) {
// emulate autocompletion for valid values that aren't predefined
while (value < minimum()) {
value *= 10;
if (value > maximum()) {
// autocompletion cannot be emulated for this value
return;
}
}
LineEditSelectionKeeper selectionKeeper(lineEdit());
if (fuzzyEqual(value, d->value)) {
updateDisplayedText();
} else {
d->lastCustomZoomValue = value;
activateAndChangeZoomTo(-1);
}
}
}
});
connect(this, qOverload<int>(&ZoomComboBox::highlighted), this, &ZoomComboBox::changeZoomTo);
view()->installEventFilter(this);
connect(this, qOverload<int>(&ZoomComboBox::activated), this, &ZoomComboBox::activateAndChangeZoomTo);
}
ZoomComboBox::~ZoomComboBox() noexcept = default;
void ZoomComboBox::setActions(QAction *zoomToFitAction, QAction *zoomToFillAction, QAction *actualSizeAction)
{
Q_D(ZoomComboBox);
d->setActions(zoomToFitAction, zoomToFillAction, actualSizeAction);
connect(zoomToFitAction, &QAction::toggled, this, &ZoomComboBox::updateDisplayedText);
connect(zoomToFillAction, &QAction::toggled, this, &ZoomComboBox::updateDisplayedText);
}
void ZoomComboBoxPrivate::setActions(QAction *zoomToFitAction, QAction *zoomToFillAction, QAction *actualSizeAction)
{
Q_Q(ZoomComboBox);
q->clear();
q->addItem(zoomToFitAction->iconText(), QVariant::fromValue(zoomToFitAction)); // index = 0
q->addItem(zoomToFillAction->iconText(), QVariant::fromValue(zoomToFillAction)); // index = 1
q->addItem(actualSizeAction->iconText(), QVariant::fromValue(actualSizeAction)); // index will change later
mZoomToFitAction = zoomToFitAction;
mZoomToFillAction = zoomToFillAction;
mActualSizeAction = actualSizeAction;
}
qreal ZoomComboBox::value() const
{
Q_D(const ZoomComboBox);
return d->value;
}
void ZoomComboBox::setValue(qreal value)
{
Q_D(ZoomComboBox);
d->value = value;
updateDisplayedText();
}
qreal ZoomComboBox::minimum() const
{
Q_D(const ZoomComboBox);
return d->validator->minimum();
}
void ZoomComboBox::setMinimum(qreal minimum)
{
Q_D(ZoomComboBox);
if (fuzzyEqual(this->minimum(), minimum)) {
return;
}
d->validator->setMinimum(minimum);
setValue(qMax(minimum, d->value));
// Generate zoom presets below 100%
// FIXME: combobox value gets reset to last index value when this code runs
const int zoomToFillActionIndex = findData(QVariant::fromValue(d->mZoomToFillAction));
const int actualSizeActionIndex = findData(QVariant::fromValue(d->mActualSizeAction));
for (int i = actualSizeActionIndex - 1; i > zoomToFillActionIndex; --i) {
removeItem(i);
}
qreal value = minimum * 2; // The minimum zoom value itself is already available through "fit".
for (int i = zoomToFillActionIndex + 1; value < 1.0; ++i) {
insertItem(i, textFromValue(value), QVariant::fromValue(value));
value *= 2;
}
}
qreal ZoomComboBox::maximum() const
{
Q_D(const ZoomComboBox);
return d->validator->maximum();
}
void ZoomComboBox::setMaximum(qreal maximum)
{
Q_D(ZoomComboBox);
if (fuzzyEqual(this->maximum(), maximum)) {
return;
}
d->validator->setMaximum(maximum);
setValue(qMin(d->value, maximum));
// Generate zoom presets above 100%
// NOTE: This probably has the same problem as setMinimum(),
// but the problem is never enountered since max zoom doesn't actually change
const int actualSizeActionIndex = findData(QVariant::fromValue(d->mActualSizeAction));
const int count = this->count();
for (int i = actualSizeActionIndex + 1; i < count; ++i) {
removeItem(i);
}
qreal value = 2.0;
while (value < maximum) {
addItem(textFromValue(value), QVariant::fromValue(value));
value *= 2;
}
if (fuzzyGreaterEqual(value, maximum)) {
addItem(textFromValue(maximum), QVariant::fromValue(maximum));
}
}
qreal ZoomComboBox::valueFromText(const QString &text, bool *ok) const
{
Q_D(const ZoomComboBox);
const QLocale l = locale();
QString s = text;
s.remove(l.groupSeparator());
if (s.endsWith(l.percent())) {
s = s.chopped(1);
}
if (s.startsWith(l.percent())) {
s = s.remove(0, 1);
}
return l.toDouble(s, ok) / 100.0;
}
QString ZoomComboBox::textFromValue(const qreal value) const
{
Q_D(const ZoomComboBox);
QLocale l = locale();
l.setNumberOptions(QLocale::OmitGroupSeparator);
QString formattedValue = l.toString(qRound(value * 100));
return i18nc("Percent value", "%1%", formattedValue);
}
void ZoomComboBox::updateDisplayedText()
{
Q_D(ZoomComboBox);
if (d->mZoomToFitAction->isChecked()) {
lineEdit()->setText(d->mZoomToFitAction->iconText());
} else if (d->mZoomToFillAction->isChecked()) {
lineEdit()->setText(d->mZoomToFillAction->iconText());
} else if (d->mActualSizeAction->isChecked()) {
lineEdit()->setText(d->mActualSizeAction->iconText());
} else {
lineEdit()->setText(textFromValue(d->value));
}
}
void Gwenview::ZoomComboBox::showPopup()
{
updateCurrentIndex();
// We don't want to emit a QComboBox::highlighted event just because the popup is opened.
const QSignalBlocker blocker(this);
QComboBox::showPopup();
}
bool ZoomComboBox::eventFilter(QObject *watched, QEvent *event)
{
if (watched == view()) {
switch (event->type()) {
case QEvent::Hide: {
Q_D(ZoomComboBox);
changeZoomTo(d->lastSelectedIndex);
break;
}
case QEvent::ShortcutOverride: {
if (view()->isVisibleTo(this)) {
auto keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Escape) {
event->accept();
}
}
break;
}
default:
break;
}
}
return QComboBox::eventFilter(watched, event);
}
void ZoomComboBox::focusOutEvent(QFocusEvent *event)
{
Q_D(ZoomComboBox);
// Should the user have started typing a custom value
// that was out of our range, then we have a temporary
// state that is illegal. This is needed to allow a user
// to type a zoom with multiple keystrokes, but when the
// user leaves focus we should reset to the last known 'good'
// zoom value.
if (d->lastSelectedIndex == -1)
setValue(d->lastCustomZoomValue);
QComboBox::focusOutEvent(event);
}
void ZoomComboBox::keyPressEvent(QKeyEvent *event)
{
switch (event->key()) {
case Qt::Key_Down:
case Qt::Key_Up:
case Qt::Key_PageDown:
case Qt::Key_PageUp: {
updateCurrentIndex();
if (currentIndex() != -1) {
break;
}
moveCurrentIndex(event->key() == Qt::Key_Down || event->key() == Qt::Key_PageDown);
return;
}
default:
break;
}
QComboBox::keyPressEvent(event);
}
void ZoomComboBox::wheelEvent(QWheelEvent *event)
{
updateCurrentIndex();
if (currentIndex() != -1) {
// Everything should work as expected.
QComboBox::wheelEvent(event);
return;
}
moveCurrentIndex(event->angleDelta().y() < 0);
}
void ZoomComboBox::updateCurrentIndex()
{
Q_D(ZoomComboBox);
if (d->mZoomToFitAction->isChecked()) {
setCurrentIndex(0);
d->lastSelectedIndex = 0;
} else if (d->mZoomToFillAction->isChecked()) {
setCurrentIndex(1);
d->lastSelectedIndex = 1;
} else if (d->mActualSizeAction->isChecked()) {
const int actualSizeActionIndex = findData(QVariant::fromValue(d->mActualSizeAction));
setCurrentIndex(actualSizeActionIndex);
d->lastSelectedIndex = actualSizeActionIndex;
} else {
// Now is a good time to save the zoom value that was selected before the user changes it through the popup.
d->lastCustomZoomValue = d->value;
// Highlight the correct index if the current zoom value exists as an option in the popup.
// If it doesn't exist, it is set to -1.
d->lastSelectedIndex = findText(textFromValue(d->value));
setCurrentIndex(d->lastSelectedIndex);
}
}
void ZoomComboBox::moveCurrentIndex(bool moveUp)
{
// There is no exact match for the current zoom value in the
// ComboBox. We need to find the closest matches, so scrolling
// works as expected.
Q_D(ZoomComboBox);
int newIndex;
if (moveUp) {
// switch to a larger item
newIndex = count() - 1;
for (int i = 2; i < newIndex; ++i) {
const QVariant data = itemData(i);
qreal value;
if (data.value<QAction *>() == d->mActualSizeAction) {
value = 1;
} else {
value = data.value<qreal>();
}
if (value > d->value) {
newIndex = i;
break;
}
}
} else {
// switch to a smaller item
newIndex = 1;
for (int i = count() - 1; i > newIndex; --i) {
const QVariant data = itemData(i);
qreal value;
if (data.value<QAction *>() == d->mActualSizeAction) {
value = 1;
} else {
value = data.value<qreal>();
}
if (value < d->value) {
newIndex = i;
break;
}
}
}
setCurrentIndex(newIndex);
Q_EMIT activated(newIndex);
}
void ZoomComboBox::changeZoomTo(int index)
{
if (index < 0) {
Q_D(ZoomComboBox);
Q_EMIT zoomChanged(d->lastCustomZoomValue);
return;
}
QVariant itemData = this->itemData(index);
auto action = itemData.value<QAction *>();
if (action) {
if (!action->isCheckable() || !action->isChecked()) {
action->trigger();
}
} else if (itemData.canConvert(QMetaType::QReal)) {
Q_EMIT zoomChanged(itemData.toReal());
}
}
void ZoomComboBox::activateAndChangeZoomTo(int index)
{
Q_D(ZoomComboBox);
d->lastSelectedIndex = index;
// The user has explicitly selected this zoom value so we
// remember it the same way as if they had typed it themselves.
QVariant itemData = this->itemData(index);
if (!itemData.value<QAction *>() && itemData.canConvert(QMetaType::QReal)) {
d->lastCustomZoomValue = itemData.toReal();
}
changeZoomTo(index);
}
#include "moc_zoomcombobox.cpp"
#include "moc_zoomcombobox_p.cpp"