Verified Commit 14feacc9 authored by Daniel Vrátil's avatar Daniel Vrátil 🤖
Browse files

Implement TagSelectionComboBox

Based on old TagSelectionCombo and TagCombo, but with much
improved interaction with Akonadi.
parent d4fff010
cmake_minimum_required(VERSION 3.5)
set(PIM_VERSION "5.14.42")
set(PIM_VERSION "5.14.43")
project(Akonadi VERSION ${PIM_VERSION})
set(CMAKE_CXX_STANDARD 17)
......
......@@ -5,3 +5,4 @@ endmacro()
add_akonadi_isolated_widget_test(tageditwidgettest.cpp)
add_akonadi_isolated_widget_test(tagwidgettest.cpp)
add_akonadi_isolated_widget_test(subscriptiondialogtest.cpp)
add_akonadi_isolated_widget_test(tagselectioncomboboxtest.cpp)
/*
* Copyright 2020 Daniel Vrátil <dvratil@kde.org>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License or (at your option) version 3 or any later version
* accepted by the membership of KDE e.V. (or its successor approved
* by the membership of KDE e.V.), which shall act as a proxy
* defined in Section 14 of version 3 of the license.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
#include "qtest_akonadi.h"
#include <qnamespace.h>
#include <qtestkeyboard.h>
#include <qtestmouse.h>
#include <qtestsupport_core.h>
#include <shared/aktest.h>
#include "tagselectioncombobox.h"
#include "tagmodel.h"
#include "monitor.h"
#include "tag.h"
#include "tagdeletejob.h"
#include "tagcreatejob.h"
#include <QSignalSpy>
#include <QTest>
#include <QLineEdit>
#include <QAbstractItemView>
#include <memory>
using namespace Akonadi;
class TagSelectionComboBoxTest: public QObject
{
Q_OBJECT
struct TestSetup {
TestSetup(bool checkable)
{
widget = std::make_unique<TagSelectionComboBox>();
widget->setCheckable(checkable);
widget->show();
monitor = widget->findChild<Monitor*>();
QVERIFY(monitor);
model = widget->findChild<TagModel*>();
QVERIFY(model);
QSignalSpy modelSpy(model, &TagModel::populated);
QVERIFY(modelSpy.wait());
QVERIFY(QTest::qWaitForWindowActive(widget.get()));
valid = true;
}
~TestSetup()
{
if (!createdTags.empty()) {
auto deleteJob = new TagDeleteJob(createdTags);
AKVERIFYEXEC(deleteJob);
}
}
bool createTags(int count)
{
const auto doCreateTags = [this, count]() {
QSignalSpy monitorSpy(monitor, &Monitor::tagAdded);
for (int i = 0; i < count; ++i) {
auto job = new TagCreateJob(Tag(QStringLiteral("TestTag-%1").arg(i)));
AKVERIFYEXEC(job);
createdTags.push_back(job->tag());
}
QTRY_COMPARE(monitorSpy.count(), count);
};
doCreateTags();
return createdTags.size() == count;
}
bool testSelectionMatches(QSignalSpy &selectionSpy, const Tag::List &selection)
{
QStringList names;
std::transform(selection.begin(), selection.end(), std::back_inserter(names), std::bind(&Tag::name, std::placeholders::_1));
AKCOMPARE(widget->selection(), selection);
AKCOMPARE(widget->selectionNames(), names);
AKCOMPARE(selectionSpy.size(), 1);
AKCOMPARE(selectionSpy.at(0).at(0).value<Tag::List>(), selection);
AKCOMPARE(widget->currentText(), QLocale{}.createSeparatedList(names));
return true;
}
bool selectTagsInComboBox(const Tag::List &/*selection*/)
{
const auto windows = QApplication::topLevelWidgets();
for (auto *window : windows) {
if (auto *combo = qobject_cast<TagSelectionComboBox*>(window)) {
QTest::mouseClick(combo, Qt::LeftButton);
return true;
}
}
return false;
}
bool toggleDropdown()
{
auto view = widget->view()->parentWidget();
const bool visible = view->isVisible();
QTest::mouseClick(widget->lineEdit(), Qt::LeftButton);
QTest::qWait(10);
AKCOMPARE(view->isVisible(), !visible);
return true;
}
QModelIndex indexForTag(const Tag &tag)
{
for (int i = 0; i < widget->model()->rowCount(); ++i) {
const auto index = widget->model()->index(i, 0);
if (widget->model()->data(index, TagModel::TagRole).value<Tag>().name() == tag.name()) {
return index;
}
}
return {};
}
std::unique_ptr<TagSelectionComboBox> widget;
Monitor *monitor = nullptr;
TagModel *model = nullptr;
Tag::List createdTags;
bool valid = false;
};
public:
TagSelectionComboBoxTest()
{
qRegisterMetaType<Akonadi::Tag::List>();
}
private Q_SLOTS:
void initTestCase()
{
AkonadiTest::checkTestIsIsolated();
}
void testInitialState()
{
TestSetup test{true};
QVERIFY(test.valid);
QVERIFY(test.widget->currentText().isEmpty());
QVERIFY(test.widget->selection().isEmpty());
}
void testSettingSelectionFromCode()
{
TestSetup test{true};
QVERIFY(test.valid);
QVERIFY(test.createTags(4));
QSignalSpy selectionSpy(test.widget.get(), &TagSelectionComboBox::selectionChanged);
const auto selection = Tag::List{test.createdTags[1], test.createdTags[3]};
test.widget->setSelection(selection);
QVERIFY(test.testSelectionMatches(selectionSpy, selection));
}
void testSettingSelectionByName()
{
TestSetup test{true};
QVERIFY(test.valid);
QVERIFY(test.createTags(4));
QSignalSpy selectionSpy(test.widget.get(), &TagSelectionComboBox::selectionChanged);
const auto selection = QStringList{test.createdTags[1].name(), test.createdTags[3].name()};
test.widget->setSelection(selection);
QVERIFY(test.testSelectionMatches(selectionSpy, {test.createdTags[1], test.createdTags[3]}));
}
void testSelectionByKeyboard()
{
TestSetup test{true};
QVERIFY(test.valid);
QVERIFY(test.createTags(4));
QSignalSpy selectionSpy(test.widget.get(), &TagSelectionComboBox::selectionChanged);
const auto selection = Tag::List{test.createdTags[1], test.createdTags[3]};
QVERIFY(!test.widget->view()->parentWidget()->isVisible());
QVERIFY(test.toggleDropdown());
QTest::keyClick(test.widget->view(), Qt::Key_Down); // from name to tag 1
QTest::keyClick(test.widget->view(), Qt::Key_Down); // from tag 1 to tag 2
QTest::keyClick(test.widget->view(), Qt::Key_Space); // select tag 2
QTest::keyClick(test.widget->view(), Qt::Key_Down); // from tag 2 to tag 3
QTest::keyClick(test.widget->view(), Qt::Key_Down); // from tag 3 to tag 4
QTest::keyClick(test.widget->view(), Qt::Key_Space); // select tag 4
QTest::keyClick(test.widget->view(), Qt::Key_Escape); // close
QTest::qWait(100);
QVERIFY(!test.widget->view()->parentWidget()->isVisible());
QCOMPARE(selectionSpy.size(), 2); // two selections -> two signals
selectionSpy.takeFirst(); // remove the first one
QVERIFY(test.testSelectionMatches(selectionSpy, selection));
}
void testNonCheckableSelection()
{
TestSetup test{false};
QVERIFY(test.valid);
QVERIFY(test.createTags(4));
test.widget->setCurrentIndex(1);
QCOMPARE(test.widget->currentData(TagModel::TagRole).value<Tag>(), test.createdTags[0]);
QCOMPARE(test.widget->selection(), Tag::List{test.createdTags[0]});
QCOMPARE(test.widget->selectionNames(), QStringList{test.createdTags[0].name()});
test.widget->setSelection({test.createdTags[1]});
QCOMPARE(test.widget->currentIndex(), 2);
}
};
QTEST_AKONADIMAIN(TagSelectionComboBoxTest)
#include "tagselectioncomboboxtest.moc"
......@@ -35,6 +35,7 @@ set(akonadiwidgets_SRCS
subscriptiondialog.cpp
tageditwidget.cpp
tagmanagementdialog.cpp
tagselectioncombobox.cpp
tagselectiondialog.cpp
tagwidget.cpp
tagselectwidget.cpp
......@@ -88,6 +89,7 @@ ecm_generate_headers(AkonadiWidgets_HEADERS
StandardActionManager
SubscriptionDialog
TagManagementDialog
TagSelectionComboBox
TagSelectionDialog
TagEditWidget
TagWidget
......
/*
Copyright (c) 2014 Christian Mollekopf <mollekopf@kolabsys.com>
Copyright (c) 2020 Daniel Vrátil <dvratil@kde.org>
This program is free software; you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
#include "tagselectioncombobox.h"
#include "monitor.h"
#include "tagmodel.h"
#include <QItemSelectionModel>
#include <KCheckableProxyModel>
#include <QLineEdit>
#include <QLocale>
#include <QEvent>
#include <QAbstractItemView>
#include <QKeyEvent>
#include <KLocalizedString>
#include <algorithm>
#include <variant>
using namespace Akonadi;
namespace {
template<typename List>
List tagsFromSelection(const QItemSelection &selection, int role)
{
List tags;
for (int i = 0; i < selection.size(); ++i) {
const auto indexes = selection.at(i).indexes();
std::transform(indexes.cbegin(), indexes.cend(), std::back_inserter(tags), [role](const auto &idx) {
return idx.model()->data(idx, role).template value<typename List::value_type>();
});
}
return tags;
}
QString getEditText(const QItemSelection &selection)
{
const auto tags = tagsFromSelection<Tag::List>(selection, TagModel::TagRole);
QStringList names;
names.reserve(tags.size());
std::transform(tags.cbegin(), tags.cend(), std::back_inserter(names),
std::bind(&Tag::name, std::placeholders::_1));
return QLocale{}.createSeparatedList(names);
}
} // namespace
class TagSelectionComboBox::Private
{
public:
explicit Private(TagSelectionComboBox *parent)
: q(parent)
{}
enum LoopControl {
Break,
Continue
};
template<typename Selection, typename Comp>
void setSelection(const Selection &entries, Comp &&cmp) {
if (!mModelReady) {
mPendingSelection = entries;
return;
}
const auto forEachIndex = [this, entries, cmp](auto &&func) {
for (int i = 0, cnt = tagModel->rowCount(); i < cnt; ++i) {
const auto index = tagModel->index(i, 0);
const auto tag = tagModel->data(index, TagModel::TagRole).value<Tag>();
if (std::any_of(entries.cbegin(), entries.cend(), std::bind(cmp, tag, std::placeholders::_1))) {
if (func(index) == Break) {
break;
}
}
}
};
if (mCheckable) {
QItemSelection selection;
forEachIndex([&selection](const QModelIndex &index) {
selection.push_back(QItemSelectionRange{index});
return Continue;
});
selectionModel->select(selection, QItemSelectionModel::ClearAndSelect);
} else {
forEachIndex([this](const QModelIndex &index) {
q->setCurrentIndex(index.row());
return Break;
});
}
}
void toggleItem(const QModelIndex &tagModelIndex)
{
selectionModel->select(tagModelIndex, QItemSelectionModel::Toggle);
}
void setItemChecked(const QModelIndex &tagModelIndex, Qt::CheckState state)
{
selectionModel->select(
tagModelIndex,
state == Qt::Checked ? QItemSelectionModel::Select: QItemSelectionModel::Deselect);
}
void setCheckable(bool checkable)
{
if (checkable) {
selectionModel = std::make_unique<QItemSelectionModel>(tagModel.get(), q);
checkableProxy = std::make_unique<KCheckableProxyModel>(q);
checkableProxy->setSourceModel(tagModel.get());
checkableProxy->setSelectionModel(selectionModel.get());
tagModel->setParent(nullptr);
q->setModel(checkableProxy.get());
tagModel->setParent(q);
q->setEditable(true);
q->lineEdit()->setReadOnly(true);
q->lineEdit()->setPlaceholderText(i18nc("@label Placeholder text in tag selection combobox", "Select tags..."));
q->lineEdit()->setAlignment(Qt::AlignLeft);
q->lineEdit()->installEventFilter(q);
q->view()->installEventFilter(q);
q->view()->viewport()->installEventFilter(q);
q->connect(selectionModel.get(), &QItemSelectionModel::selectionChanged,
q, [this]() {
const auto selection = selectionModel->selection();
q->setEditText(getEditText(selection));
Q_EMIT q->selectionChanged(tagsFromSelection<Tag::List>(selection, TagModel::TagRole));
});
q->connect(q, qOverload<int>(&QComboBox::activated),
selectionModel.get(), [this](int i) {
if (q->view()->isVisible()) {
const auto index = tagModel->index(i, 0);
toggleItem(index);
}
});
} else {
// QComboBox automatically deletes models that it is a parent of
// which breaks our stuff
tagModel->setParent(nullptr);
q->setModel(tagModel.get());
tagModel->setParent(q);
if (q->lineEdit()) {
q->lineEdit()->removeEventFilter(q);
}
if (q->view()) {
q->view()->removeEventFilter(q);
q->view()->viewport()->removeEventFilter(q);
}
q->setEditable(false);
selectionModel.reset();
checkableProxy.reset();
}
}
std::unique_ptr<QItemSelectionModel> selectionModel;
std::unique_ptr<TagModel> tagModel;
std::unique_ptr<KCheckableProxyModel> checkableProxy;
bool mCheckable = false;
bool mAllowHide = true;
bool mModelReady = false;
std::variant<std::monostate, Tag::List, QStringList> mPendingSelection;
private:
TagSelectionComboBox * const q;
};
TagSelectionComboBox::TagSelectionComboBox(QWidget *parent)
: QComboBox(parent)
, d(new Private(this))
{
auto monitor = new Monitor(this);
monitor->setObjectName(QStringLiteral("TagSelectionComboBoxMonitor"));
monitor->setTypeMonitored(Monitor::Tags);
d->tagModel = std::make_unique<TagModel>(monitor, this);
connect(d->tagModel.get(), &TagModel::populated, this, [this]() {
d->mModelReady = true;
if (std::holds_alternative<Tag::List>(d->mPendingSelection)) {
setSelection(std::get<Tag::List>(d->mPendingSelection));
} else if (std::holds_alternative<QStringList>(d->mPendingSelection)) {
setSelection(std::get<QStringList>(d->mPendingSelection));
}
d->mPendingSelection = std::monostate{};
});
d->setCheckable(d->mCheckable);
}
TagSelectionComboBox::~TagSelectionComboBox() = default;
void TagSelectionComboBox::setCheckable(bool checkable)
{
if (d->mCheckable != checkable) {
d->mCheckable = checkable;
d->setCheckable(d->mCheckable);
}
}
bool TagSelectionComboBox::checkable() const
{
return d->mCheckable;
}
void TagSelectionComboBox::setSelection(const Tag::List &tags)
{
d->setSelection(tags, [](const Tag &a, const Tag &b) { return a.name() == b.name(); });
}
void TagSelectionComboBox::setSelection(const QStringList &tagNames)
{
d->setSelection(tagNames, [](const Tag &a, const QString &b) { return a.name() == b; });
}
Tag::List TagSelectionComboBox::selection() const
{
if (!d->selectionModel) {
return {currentData(TagModel::TagRole).value<Tag>()};
}
return tagsFromSelection<Tag::List>(d->selectionModel->selection(), TagModel::TagRole);
}
QStringList TagSelectionComboBox::selectionNames() const
{
if (!d->selectionModel) {
return {currentText()};
}
return tagsFromSelection<QStringList>(d->selectionModel->selection(), TagModel::NameRole);
}
void TagSelectionComboBox::hidePopup()
{
if (d->mAllowHide) {
QComboBox::hidePopup();
}
d->mAllowHide = true;
}
void TagSelectionComboBox::keyPressEvent(QKeyEvent *event)
{
switch (event->key()) {
case Qt::Key_Up:
case Qt::Key_Down:
showPopup();
event->accept();
break;
case Qt::Key_Return:
case Qt::Key_Enter:
case Qt::Key_Escape:
hidePopup();
event->accept();
break;
default:
break;
}
}
bool TagSelectionComboBox::eventFilter(QObject *receiver, QEvent *event)
{
switch (event->type()) {
case QEvent::KeyPress:
case QEvent::KeyRelease:
case QEvent::ShortcutOverride:
switch (static_cast<QKeyEvent *>(event)->key()) {
case Qt::Key_Return:
case Qt::Key_Enter:
case Qt::Key_Escape:
hidePopup();
return true;
}
break;
case QEvent::MouseButtonDblClick:
case QEvent::MouseButtonPress:
case QEvent::MouseButtonRelease:
d->mAllowHide = false;
if (receiver == lineEdit()) {
showPopup();
return true;
}
break;
default:
break;
}
return QComboBox::eventFilter(receiver, event);
}
/*
Copyright (c) 2014 Christian Mollekopf <mollekopf@kolabsys.com>
Copyright (c) 2020 Daniel Vrátil <dvratil@kde.org>
This program is free software; you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU