diff --git a/CMakeLists.txt b/CMakeLists.txt index 831af77..6e3de51 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,49 +1,50 @@ cmake_minimum_required(VERSION 3.5) set(PROJECT_VERSION "1.1.0") project(plasma-plasmapass VERSION ${PROJECT_VERSION}) set(PROJECT_VERSION_MAJOR 1) set(QT_MIN_VERSION "5.11") set(KF5_MIN_VERSION "5.57.0") set(CMAKE_CXX_STANDARD 14) find_package(ECM ${KF5_MIN_VERSION} REQUIRED NO_MODULE) set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH} ${ECM_KDE_MODULE_DIR}) include(KDEInstallDirs) include(KDECMakeSettings) include(KDEFrameworkCompilerSettings NO_POLICY_SCOPE) include(ECMQtDeclareLoggingCategory) find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS Plasma I18n ItemModels ) find_package(Qt5 ${QT_MIN_VERSION} CONFIG REQUIRED COMPONENTS Core DBus Gui Qml + Concurrent ) add_definitions(-DQT_DISABLE_DEPRECATED_BEFORE=0x060000) add_definitions(-DQT_NO_FOREACH) # plasmoid plasma_install_package(package org.kde.plasma.pass) # qml extension plugin add_subdirectory(plugin) if (BUILD_TESTING) add_subdirectory(tests) endif() install( FILES plasma-pass.categories DESTINATION ${KDE_INSTALL_CONFDIR} ) feature_summary(WHAT ALL FATAL_ON_MISSING_REQUIRED_PACKAGES) diff --git a/plugin/CMakeLists.txt b/plugin/CMakeLists.txt index 94b7303..13aec5b 100644 --- a/plugin/CMakeLists.txt +++ b/plugin/CMakeLists.txt @@ -1,45 +1,46 @@ ###### STATIC LIBRARY ###### set(plasmapasslib_SRCS abbreviations.cpp passwordfiltermodel.cpp passwordsmodel.cpp passwordsortproxymodel.cpp passwordprovider.cpp ) qt5_add_dbus_interfaces(plasmapasslib_SRCS ${CMAKE_CURRENT_SOURCE_DIR}/interfaces/org.kde.klipper.klipper.xml ) ecm_qt_declare_logging_category(plasmapasslib_SRCS HEADER plasmapass_debug.h IDENTIFIER PlasmaPass::PLASMAPASS_LOG CATEGORY_NAME org.kde.plasma.pass ) add_library(plasmapass STATIC ${plasmapasslib_SRCS}) target_link_libraries(plasmapass Qt5::Core Qt5::DBus Qt5::Qml + Qt5::Concurrent KF5::Plasma KF5::I18n KF5::ItemModels ) ########### PLUGN ########### set(plasmapassplugin_SRCS plasmapassplugin.cpp ) add_library(plasmapassplugin SHARED ${plasmapassplugin_SRCS}) target_link_libraries(plasmapassplugin plasmapass ) install(TARGETS plasmapassplugin DESTINATION ${KDE_INSTALL_QMLDIR}/org/kde/plasma/private/plasmapass) install(FILES qmldir DESTINATION ${KDE_INSTALL_QMLDIR}/org/kde/plasma/private/plasmapass) diff --git a/plugin/passwordfiltermodel.cpp b/plugin/passwordfiltermodel.cpp index 3115a6c..4d21cb8 100644 --- a/plugin/passwordfiltermodel.cpp +++ b/plugin/passwordfiltermodel.cpp @@ -1,134 +1,268 @@ /* * Copyright (C) 2018 Daniel Vrátil * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Library General Public License as * published by the Free Software Foundation; either version 2, 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 Library 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 "passwordfiltermodel.h" #include "passwordsmodel.h" #include "abbreviations.h" #include #include +#include +#include #include +#include using namespace PlasmaPass; namespace { constexpr static const auto invalidateDelay = std::chrono::milliseconds(100); constexpr static const char newFilterProperty[] = "newFilter"; +class ModelIterator +{ +public: + using reference = const QModelIndex &; + using pointer = QModelIndex *; + using value_type = QModelIndex; + using difference_type = int; + using iterator_category = std::forward_iterator_tag; + + static ModelIterator begin(QAbstractItemModel *model) + { + return ModelIterator(model, model->index(0, 0)); + } + + static ModelIterator end(QAbstractItemModel *model) + { + return ModelIterator(model, {}); + } + + bool operator==(const ModelIterator &other) const { + return mModel == other.mModel && mIndex == other.mIndex; + } + + bool operator!=(const ModelIterator &other) const { + return !(*this == other); + } + + QModelIndex operator*() const { + return mIndex; + } + + const QModelIndex *operator->() const { + return &mIndex; + } + + ModelIterator &operator++() { + if (mIndex.row() < mModel->rowCount() - 1) { + mIndex = mModel->index(mIndex.row() + 1, mIndex.column()); + } else { + mIndex = {}; + } + return *this; + } + + ModelIterator operator++(int) { + ModelIterator it = *this; + ++*this; + return it; + } + +private: + ModelIterator(QAbstractItemModel *model, const QModelIndex &index) + : mModel(model), mIndex(index) + {} + +private: + QAbstractItemModel *mModel = nullptr; + QModelIndex mIndex; +}; + +} // namespace + +PasswordFilterModel::PathFilter::PathFilter(const QString &filter) + : filter(filter) +{} + +PasswordFilterModel::PathFilter::PathFilter(const PathFilter &other) + : filter(other.filter) +{ + updateParts(); } +PasswordFilterModel::PathFilter &PasswordFilterModel::PathFilter::operator=(const PathFilter &other) +{ + filter = other.filter; + updateParts(); + return *this; +} + +PasswordFilterModel::PathFilter::PathFilter(PathFilter &&other) noexcept + : filter(std::move(other.filter)) +{ + updateParts(); +} + +PasswordFilterModel::PathFilter &PasswordFilterModel::PathFilter::operator=(PathFilter &&other) noexcept +{ + filter = std::move(other.filter); + updateParts(); + return *this; +} + +void PasswordFilterModel::PathFilter::updateParts() +{ + mParts = filter.splitRef(QLatin1Char('/'), QString::SkipEmptyParts); +} + +PasswordFilterModel::PathFilter::result_type PasswordFilterModel::PathFilter::operator()(const QModelIndex &index) const { + const auto path = index.model()->data(index, PasswordsModel::FullNameRole).toString(); + const auto weight = matchPathFilter(path.splitRef(QLatin1Char('/')), mParts); + return std::make_pair(index, weight); +} PasswordFilterModel::PasswordFilterModel(QObject *parent) : QSortFilterProxyModel(parent) , mFlatModel(new KDescendantsProxyModel(this)) { mFlatModel->setDisplayAncestorData(false); sort(0); // enable sorting mUpdateTimer.setSingleShot(true); connect(&mUpdateTimer, &QTimer::timeout, this, &PasswordFilterModel::delayedUpdateFilter); + connect(&mUpdateTimer, &QTimer::timeout, this, []() { qDebug() << "Update timer timeout, will calculate results lazily."; }); } void PasswordFilterModel::setSourceModel(QAbstractItemModel *sourceModel) { mFlatModel->setSourceModel(sourceModel); if (!this->sourceModel()) { QSortFilterProxyModel::setSourceModel(mFlatModel); } } QString PasswordFilterModel::passwordFilter() const { - return mFilter; + return mFilter.filter; } + void PasswordFilterModel::setPasswordFilter(const QString &filter) { - if (mFilter != filter) { + if (mFilter.filter != filter) { if (mUpdateTimer.isActive()) { mUpdateTimer.stop(); } mUpdateTimer.setProperty(newFilterProperty, filter); mUpdateTimer.start(invalidateDelay); + + if (mFuture.isRunning()) { + mFuture.cancel(); + } + if (!filter.isEmpty()) { + mFuture = QtConcurrent::mappedReduced>( + ModelIterator::begin(sourceModel()), ModelIterator::end(sourceModel()), + PathFilter{filter}, + [](QHash &result, const std::pair &value) { + result.insert(value.first, value.second); + }); + auto watcher = new QFutureWatcher>(); + connect(watcher, &QFutureWatcherBase::finished, this, + [this, watcher]() { + mSortingLookup = mFuture.result(); + watcher->deleteLater(); + // If the timer is not active it means we were to slow, so don't invoke + // delayedUpdateFilter() again, just update mSortingLookup with our + // results. + if (mUpdateTimer.isActive()) { + mUpdateTimer.stop(); + delayedUpdateFilter(); + } + }); + connect(watcher, &QFutureWatcherBase::canceled, watcher, &QObject::deleteLater); + watcher->setFuture(mFuture); + } } } void PasswordFilterModel::delayedUpdateFilter() { - Q_ASSERT(sender() == &mUpdateTimer); - - mFilter = mUpdateTimer.property(newFilterProperty).toString(); - mParts = mFilter.splitRef(QLatin1Char('/'), QString::SkipEmptyParts); + mFilter = PathFilter(mUpdateTimer.property(newFilterProperty).toString()); Q_EMIT passwordFilterChanged(); - mSortingLookup.clear(); + if (mFuture.isRunning()) { + // If the future is still running, clear up the lookup table, we will have to + // calculate the intermediate results ourselves + mSortingLookup.clear(); + } invalidate(); } QVariant PasswordFilterModel::data(const QModelIndex &index, int role) const { if (role == Qt::DisplayRole) { return data(index, PasswordsModel::FullNameRole); } return QSortFilterProxyModel::data(index, role); } bool PasswordFilterModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const { const auto src_index = sourceModel()->index(source_row, 0, source_parent); const auto type = static_cast(sourceModel()->data(src_index, PasswordsModel::EntryTypeRole).toInt()); if (type == PasswordsModel::FolderEntry) { return false; } - if (mFilter.isEmpty()) { + if (mFilter.filter.isEmpty()) { return true; } - const auto path = sourceModel()->data(src_index, PasswordsModel::FullNameRole).toString(); - - const auto weight = matchPathFilter(path.splitRef(QLatin1Char('/')), mParts); - if (weight > -1) { - mSortingLookup.insert(src_index, weight); - return true; + // Try to lookup the weight in the lookup table, the worker thread may have put it in there + // while the updateTimer was ticking + auto weight = mSortingLookup.find(src_index); + if (weight == mSortingLookup.end()) { + // It's not there, let's calculate the value now + const auto result = mFilter(src_index); + weight = mSortingLookup.insert(result.first, result.second); } - return false; + return *weight > -1; } bool PasswordFilterModel::lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const { const auto weightLeft = mSortingLookup.value(source_left, -1); const auto weightRight = mSortingLookup.value(source_right, -1); if (weightLeft == weightRight) { const auto nameLeft = source_left.data(PasswordsModel::FullNameRole).toString(); const auto nameRight = source_right.data(PasswordsModel::FullNameRole).toString(); return QString::localeAwareCompare(nameLeft, nameRight) < 0; } return weightLeft < weightRight; } diff --git a/plugin/passwordfiltermodel.h b/plugin/passwordfiltermodel.h index 9f2d7b7..56998e6 100644 --- a/plugin/passwordfiltermodel.h +++ b/plugin/passwordfiltermodel.h @@ -1,66 +1,86 @@ /* * Copyright (C) 2018 Daniel Vrátil * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Library General Public License as * published by the Free Software Foundation; either version 2, 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 Library 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. */ #ifndef PASSWORDFILTERMODEL_H_ #define PASSWORDFILTERMODEL_H_ #include #include #include +#include class QStringRef; class KDescendantsProxyModel; namespace PlasmaPass { class PasswordFilterModel : public QSortFilterProxyModel { Q_OBJECT Q_PROPERTY(QString passwordFilter READ passwordFilter WRITE setPasswordFilter NOTIFY passwordFilterChanged) public: explicit PasswordFilterModel(QObject *parent = nullptr); void setSourceModel(QAbstractItemModel *sourceModel) override; QString passwordFilter() const; void setPasswordFilter(const QString &filter); QVariant data(const QModelIndex &index, int role) const override; Q_SIGNALS: void passwordFilterChanged(); protected: bool lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const override; bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; private: + struct PathFilter { + using result_type = std::pair; + + explicit PathFilter() = default; + PathFilter(const QString &filter); + + PathFilter(const PathFilter &); + PathFilter(PathFilter &&) noexcept; + PathFilter &operator=(const PathFilter &); + PathFilter &operator=(PathFilter &&) noexcept; + + result_type operator()(const QModelIndex &index) const; + + QString filter; + private: + void updateParts(); + QVector mParts; + }; + void delayedUpdateFilter(); KDescendantsProxyModel *mFlatModel = nullptr; - QString mFilter; - QVector mParts; + PathFilter mFilter; mutable QHash mSortingLookup; QTimer mUpdateTimer; + QFuture> mFuture; }; } #endif diff --git a/tests/passwordsmodeltest/mainwindow.cpp b/tests/passwordsmodeltest/mainwindow.cpp index fc56a5f..698204f 100644 --- a/tests/passwordsmodeltest/mainwindow.cpp +++ b/tests/passwordsmodeltest/mainwindow.cpp @@ -1,146 +1,179 @@ /* * Copyright (C) 2018 Daniel Vrátil * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Library General Public License as * published by the Free Software Foundation; either version 2, 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 Library 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 "mainwindow.h" #include "passwordsmodel.h" #include "passwordprovider.h" +#include "passwordfiltermodel.h" #include #include #include #include #include #include #include +#include +#include +#include using namespace PlasmaPass; MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { resize(900, 350); QWidget *w = new QWidget; setCentralWidget(w); auto h = new QHBoxLayout(w); auto splitter = new QSplitter; h->addWidget(splitter); + w = new QWidget; + splitter->addWidget(w); + + auto v = new QVBoxLayout(w); + + auto input = new QLineEdit; + input->setClearButtonEnabled(true); + input->setPlaceholderText(QStringLiteral("Search ...")); + connect(input, &QLineEdit::textChanged, this, &MainWindow::onSearchChanged); + v->addWidget(input); + + mStack = new QStackedWidget; + v->addWidget(mStack); + auto treeView = new QTreeView; treeView->setHeaderHidden(true); treeView->setModel(new PasswordsModel(this)); connect(treeView, &QTreeView::clicked, this, &MainWindow::onPasswordClicked); - splitter->addWidget(treeView); + mStack->addWidget(treeView); + + auto listView = new QListView; + mFilterModel = new PasswordFilterModel(listView); + mFilterModel->setSourceModel(treeView->model()); + listView->setModel(mFilterModel); + connect(listView, &QListView::clicked, this, &MainWindow::onPasswordClicked); + mStack->addWidget(listView); + + mStack->setCurrentIndex(0); w = new QWidget; splitter->addWidget(w); - auto v = new QVBoxLayout(w); + v = new QVBoxLayout(w); v->addWidget(mTitle = new QLabel); auto font = mTitle->font(); font.setBold(true); mTitle->setFont(font); auto g = new QFormLayout; v->addLayout(g); g->addRow(QStringLiteral("Path:"), mPath = new QLabel()); g->addRow(QStringLiteral("Type:"), mType = new QLabel()); g->addRow(QStringLiteral("Password:"), mPassword = new QLabel()); g->addRow(QStringLiteral("Expiration:"), mPassProgress = new QProgressBar()); g->addRow(QStringLiteral("Error:"), mError = new QLabel()); mPassProgress->setTextVisible(false); v->addWidget(mPassBtn = new QPushButton(QStringLiteral("Display Password"))); connect(mPassBtn, &QPushButton::clicked, this, [this]() { setProvider(mCurrent.data(PasswordsModel::PasswordRole).value()); }); v->addStretch(2.0); onPasswordClicked({}); } MainWindow::~MainWindow() { } void MainWindow::setProvider(PasswordProvider *provider) { mProvider = provider; if (provider->isValid()) { mPassBtn->setVisible(false); mPassword->setVisible(true); mPassword->setText(provider->password()); } if (provider->hasError()) { mError->setVisible(true); mError->setText(provider->error()); } connect(provider, &PasswordProvider::passwordChanged, this, [this, provider]() { const auto pass = provider->password(); if (!pass.isEmpty()) { mPassword->setVisible(true); mPassword->setText(provider->password()); } else { onPasswordClicked(mCurrent); } }); connect(provider, &PasswordProvider::timeoutChanged, this, [this, provider]() { mPassProgress->setVisible(true); mPassProgress->setMaximum(provider->defaultTimeout()); mPassProgress->setValue(provider->timeout()); }); connect(provider, &PasswordProvider::errorChanged, this, [this, provider]() { mError->setVisible(true); mError->setText(provider->error()); }); } void MainWindow::onPasswordClicked(const QModelIndex &idx) { if (mProvider) { mProvider->disconnect(this); } mCurrent = idx; mTitle->setText(idx.data(PasswordsModel::NameRole).toString()); mPath->setText(idx.data(PasswordsModel::PathRole).toString()); const auto type = idx.isValid() ? static_cast(idx.data(PasswordsModel::EntryTypeRole).toInt()) : PasswordsModel::FolderEntry; mType->setText(type == PasswordsModel::PasswordEntry ? QStringLiteral("Password") : QStringLiteral("Folder")); mPassword->setVisible(false); mPassword->clear(); mPassBtn->setEnabled(type == PasswordsModel::PasswordEntry); mPassBtn->setVisible(true); mPassProgress->setVisible(false); mError->clear(); mError->setVisible(false); const auto hasProvider = mCurrent.data(PasswordsModel::HasPasswordRole).toBool(); if (hasProvider) { setProvider(mCurrent.data(PasswordsModel::PasswordRole).value()); } } + +void MainWindow::onSearchChanged(const QString &text) +{ + mStack->setCurrentIndex(text.isEmpty() ? 0 : 1); + mFilterModel->setPasswordFilter(text); +} diff --git a/tests/passwordsmodeltest/mainwindow.h b/tests/passwordsmodeltest/mainwindow.h index edf36b5..1779258 100644 --- a/tests/passwordsmodeltest/mainwindow.h +++ b/tests/passwordsmodeltest/mainwindow.h @@ -1,60 +1,65 @@ /* * Copyright (C) 2018 Daniel Vrátil * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Library General Public License as * published by the Free Software Foundation; either version 2, 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 Library 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. */ #ifndef MAINWINDOW_H #define MAINWINDOW_H #include #include #include class QLabel; class QPushButton; class QProgressBar; +class QStackedWidget; namespace PlasmaPass { class PasswordProvider; +class PasswordFilterModel; } class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(QWidget *parent = nullptr); ~MainWindow() override; private Q_SLOTS: void onPasswordClicked(const QModelIndex &idx); + void onSearchChanged(const QString &text); private: void setProvider(PlasmaPass::PasswordProvider *provider); QLabel *mTitle = nullptr; QLabel *mType = nullptr; QLabel *mPath = nullptr; QLabel *mPassword = nullptr; QLabel *mError = nullptr; QPushButton *mPassBtn = nullptr; QProgressBar *mPassProgress = nullptr; QModelIndex mCurrent; QPointer mProvider; + QStackedWidget *mStack = nullptr; + PlasmaPass::PasswordFilterModel *mFilterModel = nullptr; }; #endif