From 4a21cee98c30cb841790c13d5a6956224233fedc Mon Sep 17 00:00:00 2001 From: Xavier Valls Date: Sun, 23 Jan 2022 10:00:48 -0500 Subject: [PATCH] Add tags feature * show the tags in the entry preview * allow searching by tag * add a sidebar listing the tags in the database * filter entries by tag on click * Introduce a new TagsEdit widget that provides pill aesthetics, fast removal functionality and autocompletion * add tests for the tags feature * introduce the "is" tag for searching. Support for weak passwords and expired added. --- COPYING | 2 + cmake/CLangFormat.cmake | 1 + .../scalable/actions/tag-search.svg | 1 + .../application/scalable/actions/tag.svg | 1 + .../application/scalable/categories/label.svg | 1 + share/icons/icons.qrc | 2 + share/translations/keepassxc_en.ts | 35 + src/CMakeLists.txt | 2 + src/core/Config.cpp | 1 + src/core/Config.h | 1 + src/core/Database.cpp | 38 +- src/core/Database.h | 8 +- src/core/Entry.cpp | 16 +- src/core/Entry.h | 4 +- src/core/EntrySearcher.cpp | 28 +- src/core/EntrySearcher.h | 4 +- src/gui/DatabaseWidget.cpp | 114 ++- src/gui/DatabaseWidget.h | 14 +- src/gui/DatabaseWidgetStateSync.cpp | 31 +- src/gui/DatabaseWidgetStateSync.h | 3 +- src/gui/EntryPreviewWidget.cpp | 2 + src/gui/EntryPreviewWidget.ui | 42 +- src/gui/MainWindow.cpp | 12 +- src/gui/SearchWidget.cpp | 1 + src/gui/entry/EditEntryWidget.cpp | 3 + src/gui/entry/EditEntryWidgetMain.ui | 35 +- src/gui/styles/base/basestyle.qss | 4 + src/gui/styles/base/classicstyle.qss | 4 + src/gui/tag/TagModel.cpp | 89 ++ src/gui/tag/TagModel.h | 48 + src/gui/tag/TagsEdit.cpp | 963 ++++++++++++++++++ src/gui/tag/TagsEdit.h | 78 ++ tests/gui/TestGui.cpp | 26 + 33 files changed, 1541 insertions(+), 73 deletions(-) create mode 100644 share/icons/application/scalable/actions/tag-search.svg create mode 100644 share/icons/application/scalable/actions/tag.svg create mode 100644 share/icons/application/scalable/categories/label.svg create mode 100644 src/gui/tag/TagModel.cpp create mode 100644 src/gui/tag/TagModel.h create mode 100644 src/gui/tag/TagsEdit.cpp create mode 100644 src/gui/tag/TagsEdit.h diff --git a/COPYING b/COPYING index 6eec65ea4..65b7554d8 100644 --- a/COPYING +++ b/COPYING @@ -191,6 +191,8 @@ Files: share/icons/application/scalable/actions/chevron-double-down.svg share/icons/application/scalable/actions/statistics.svg share/icons/application/scalable/actions/system-help.svg share/icons/application/scalable/actions/system-search.svg + share/icons/application/scalable/actions/tag.svg + share/icons/application/scalable/actions/tag-search.svg share/icons/application/scalable/actions/trash.svg share/icons/application/scalable/actions/url-copy.svg share/icons/application/scalable/actions/username-copy.svg diff --git a/cmake/CLangFormat.cmake b/cmake/CLangFormat.cmake index cdb44a6da..b2df97d4d 100644 --- a/cmake/CLangFormat.cmake +++ b/cmake/CLangFormat.cmake @@ -27,6 +27,7 @@ set(EXCLUDED_FILES src/streams/qtiocompressor.\\* src/gui/KMessageWidget.\\* src/gui/MainWindowAdaptor.\\* + src/gui/tag/TagsEdit.\\* tests/modeltest.\\* # objective-c files src/core/ScreenLockListenerMac.\\*) diff --git a/share/icons/application/scalable/actions/tag-search.svg b/share/icons/application/scalable/actions/tag-search.svg new file mode 100644 index 000000000..aab5f8162 --- /dev/null +++ b/share/icons/application/scalable/actions/tag-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/application/scalable/actions/tag.svg b/share/icons/application/scalable/actions/tag.svg new file mode 100644 index 000000000..bee670b82 --- /dev/null +++ b/share/icons/application/scalable/actions/tag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/application/scalable/categories/label.svg b/share/icons/application/scalable/categories/label.svg new file mode 100644 index 000000000..e9983b3a1 --- /dev/null +++ b/share/icons/application/scalable/categories/label.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/icons.qrc b/share/icons/icons.qrc index a0d690505..86a3abe39 100644 --- a/share/icons/icons.qrc +++ b/share/icons/icons.qrc @@ -72,6 +72,8 @@ application/scalable/actions/system-help.svg application/scalable/actions/system-search.svg application/scalable/actions/system-software-update.svg + application/scalable/actions/tag.svg + application/scalable/actions/tag-search.svg application/scalable/actions/trash.svg application/scalable/actions/url-copy.svg application/scalable/actions/user-guide.svg diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index 1318fc3d6..af2f63d76 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -2398,6 +2398,10 @@ Disable safe saves and try again? Perform Auto-Type into the previously active window? + + Database Tags + + EditEntryWidget @@ -2886,6 +2890,14 @@ Would you like to correct it? Edit Entry + + Tags: + + + + Tags list + + EditEntryWidgetSSHAgent @@ -3869,6 +3881,14 @@ Would you like to overwrite the existing attachment? Default Sequence + + Tags + + + + Tags list + + EntryURLModel @@ -8501,6 +8521,21 @@ Please consider generating a new key file. + + TagModel + + All + + + + Expired + + + + Weak Passwords + + + TotpDialog diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1cdb0dfc9..ead58ea7f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -150,6 +150,8 @@ set(keepassx_SOURCES gui/group/EditGroupWidget.cpp gui/group/GroupModel.cpp gui/group/GroupView.cpp + gui/tag/TagModel.cpp + gui/tag/TagsEdit.cpp gui/databasekey/KeyComponentWidget.cpp gui/databasekey/PasswordEditWidget.cpp gui/databasekey/YubiKeyEditWidget.cpp diff --git a/src/core/Config.cpp b/src/core/Config.cpp index a6d179504..0b6be5e4e 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -117,6 +117,7 @@ static const QHash configStrings = { {Config::GUI_ListViewState, {QS("GUI/ListViewState"), Local, {}}}, {Config::GUI_SearchViewState, {QS("GUI/SearchViewState"), Local, {}}}, {Config::GUI_SplitterState, {QS("GUI/SplitterState"), Local, {}}}, + {Config::GUI_GroupSplitterState, {QS("GUI/GroupSplitterState"), Local, {}}}, {Config::GUI_PreviewSplitterState, {QS("GUI/PreviewSplitterState"), Local, {}}}, {Config::GUI_AutoTypeSelectDialogSize, {QS("GUI/AutoTypeSelectDialogSize"), Local, QSize(600, 250)}}, diff --git a/src/core/Config.h b/src/core/Config.h index 1be8699ca..19f684293 100644 --- a/src/core/Config.h +++ b/src/core/Config.h @@ -98,6 +98,7 @@ public: GUI_SearchViewState, GUI_PreviewSplitterState, GUI_SplitterState, + GUI_GroupSplitterState, GUI_AutoTypeSelectDialogSize, GUI_CheckForUpdatesNextCheck, diff --git a/src/core/Database.cpp b/src/core/Database.cpp index 6f7dd6610..0f85546f4 100644 --- a/src/core/Database.cpp +++ b/src/core/Database.cpp @@ -52,7 +52,11 @@ Database::Database() // other signals connect(m_metadata, &Metadata::modified, this, &Database::markAsModified); - connect(this, &Database::databaseOpened, this, [this]() { updateCommonUsernames(); }); + connect(this, &Database::databaseOpened, this, [this]() { + updateCommonUsernames(); + updateTagList(); + }); + connect(this, &Database::modified, this, [this] { updateTagList(); }); connect(this, &Database::databaseSaved, this, [this]() { updateCommonUsernames(); }); connect(m_fileWatcher, &FileWatcher::fileChanged, this, &Database::databaseFileChanged); @@ -504,6 +508,7 @@ void Database::releaseData() m_deletedObjects.clear(); m_commonUsernames.clear(); + m_tagList.clear(); } /** @@ -700,17 +705,46 @@ void Database::addDeletedObject(const QUuid& uuid) addDeletedObject(delObj); } -QList Database::commonUsernames() +const QStringList& Database::commonUsernames() const { return m_commonUsernames; } +const QStringList& Database::tagList() const +{ + return m_tagList; +} + void Database::updateCommonUsernames(int topN) { m_commonUsernames.clear(); m_commonUsernames.append(rootGroup()->usernamesRecursive(topN)); } +void Database::updateTagList() +{ + m_tagList.clear(); + if (!m_rootGroup) { + emit tagListUpdated(); + return; + } + + // Search groups recursively looking for tags + // Use a set to prevent adding duplicates + QSet tagSet; + for (const auto group : m_rootGroup->groupsRecursive(true)) { + for (const auto entry : group->entries()) { + for (auto tag : entry->tagList()) { + tagSet.insert(tag); + } + } + } + + m_tagList = tagSet.toList(); + m_tagList.sort(); + emit tagListUpdated(); +} + const QUuid& Database::cipher() const { return m_data.cipher; diff --git a/src/core/Database.h b/src/core/Database.h index 31d29da7b..77abf4307 100644 --- a/src/core/Database.h +++ b/src/core/Database.h @@ -125,7 +125,8 @@ public: bool containsDeletedObject(const DeletedObject& uuid) const; void setDeletedObjects(const QList& delObjs); - QList commonUsernames(); + const QStringList& commonUsernames() const; + const QStringList& tagList() const; QSharedPointer key() const; bool setKey(const QSharedPointer& key, @@ -151,6 +152,7 @@ public slots: void markAsModified(); void markAsClean(); void updateCommonUsernames(int topN = 10); + void updateTagList(); void markNonDataChange(); signals: @@ -166,6 +168,7 @@ signals: void databaseSaved(); void databaseDiscarded(); void databaseFileChanged(); + void tagListUpdated(); private: struct DatabaseData @@ -228,7 +231,8 @@ private: bool m_hasNonDataChange = false; QString m_keyError; - QList m_commonUsernames; + QStringList m_commonUsernames; + QStringList m_tagList; QUuid m_uuid; static QHash> s_uuidMap; diff --git a/src/core/Entry.cpp b/src/core/Entry.cpp index 980ab163f..650a26ad1 100644 --- a/src/core/Entry.cpp +++ b/src/core/Entry.cpp @@ -190,6 +190,12 @@ QString Entry::tags() const return m_data.tags; } +QStringList Entry::tagList() const +{ + static QRegExp rx("(\\ |\\,|\\.|\\:|\\t|\\;)"); + return tags().split(rx, QString::SkipEmptyParts); +} + const TimeInfo& Entry::timeInfo() const { return m_data.timeInfo; @@ -210,7 +216,7 @@ QString Entry::defaultAutoTypeSequence() const return m_data.defaultAutoTypeSequence; } -const QSharedPointer& Entry::passwordHealth() +const QSharedPointer Entry::passwordHealth() { if (!m_data.passwordHealth) { m_data.passwordHealth.reset(new PasswordHealth(resolvePlaceholder(password()))); @@ -218,6 +224,14 @@ const QSharedPointer& Entry::passwordHealth() return m_data.passwordHealth; } +const QSharedPointer Entry::passwordHealth() const +{ + if (!m_data.passwordHealth) { + return QSharedPointer::create(resolvePlaceholder(password())); + } + return m_data.passwordHealth; +} + bool Entry::excludeFromReports() const { return m_data.excludeFromReports diff --git a/src/core/Entry.h b/src/core/Entry.h index 6227aa1a9..edfedc705 100644 --- a/src/core/Entry.h +++ b/src/core/Entry.h @@ -88,6 +88,7 @@ public: QString backgroundColor() const; QString overrideUrl() const; QString tags() const; + QStringList tagList() const; const TimeInfo& timeInfo() const; bool autoTypeEnabled() const; int autoTypeObfuscation() const; @@ -113,7 +114,8 @@ public: QUuid previousParentGroupUuid() const; int size() const; QString path() const; - const QSharedPointer& passwordHealth(); + const QSharedPointer passwordHealth(); + const QSharedPointer passwordHealth() const; bool excludeFromReports() const; void setExcludeFromReports(bool state); diff --git a/src/core/EntrySearcher.cpp b/src/core/EntrySearcher.cpp index 3b9472c6f..2dde80698 100644 --- a/src/core/EntrySearcher.cpp +++ b/src/core/EntrySearcher.cpp @@ -18,6 +18,7 @@ #include "EntrySearcher.h" +#include "PasswordHealth.h" #include "core/Group.h" #include "core/Tools.h" @@ -152,7 +153,7 @@ bool EntrySearcher::searchEntryImpl(const Entry* entry) auto hierarchy = entry->group()->hierarchy().join('/').prepend("/"); // By default, empty term matches every entry. - // However when skipping protected fields, we will recject everything instead + // However when skipping protected fields, we will reject everything instead bool found = !m_skipProtected; for (const auto& term : m_searchTerms) { switch (term.field) { @@ -195,11 +196,31 @@ bool EntrySearcher::searchEntryImpl(const Entry* entry) found = term.regex.match(entry->group()->name()).hasMatch(); } break; + case Field::Tag: + found = term.regex.match(entry->tags()).hasMatch(); + break; + case Field::Is: + if (term.word.compare("expired", Qt::CaseInsensitive) == 0) { + found = entry->isExpired(); + break; + } else if (term.word.compare("weak", Qt::CaseInsensitive) == 0) { + if (!entry->excludeFromReports() && !entry->password().isEmpty() && !entry->isExpired()) { + const auto quality = entry->passwordHealth()->quality(); + if (quality == PasswordHealth::Quality::Bad || quality == PasswordHealth::Quality::Poor + || quality == PasswordHealth::Quality::Weak) { + found = true; + break; + } + } + } + found = false; + break; default: // Terms without a specific field try to match title, username, url, and notes found = term.regex.match(entry->resolvePlaceholder(entry->title())).hasMatch() || term.regex.match(entry->resolvePlaceholder(entry->username())).hasMatch() || term.regex.match(entry->resolvePlaceholder(entry->url())).hasMatch() + || term.regex.match(entry->resolvePlaceholder(entry->tags())).hasMatch() || term.regex.match(entry->notes()).hasMatch(); } @@ -226,10 +247,13 @@ void EntrySearcher::parseSearchTerms(const QString& searchString) {QStringLiteral("pw"), Field::Password}, {QStringLiteral("password"), Field::Password}, {QStringLiteral("title"), Field::Title}, + {QStringLiteral("t"), Field::Title}, {QStringLiteral("u"), Field::Username}, // u: stands for username rather than url {QStringLiteral("url"), Field::Url}, {QStringLiteral("username"), Field::Username}, - {QStringLiteral("group"), Field::Group}}; + {QStringLiteral("group"), Field::Group}, + {QStringLiteral("tag"), Field::Tag}, + {QStringLiteral("is"), Field::Is}}; m_searchTerms.clear(); auto results = m_termParser.globalMatch(searchString); diff --git a/src/core/EntrySearcher.h b/src/core/EntrySearcher.h index c99639af1..80c86600c 100644 --- a/src/core/EntrySearcher.h +++ b/src/core/EntrySearcher.h @@ -38,7 +38,9 @@ public: AttributeKV, Attachment, AttributeValue, - Group + Group, + Tag, + Is }; struct SearchTerm diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp index 2caacb7a0..5fe758caa 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -50,6 +50,7 @@ #include "gui/group/EditGroupWidget.h" #include "gui/group/GroupView.h" #include "gui/reports/ReportsDialog.h" +#include "gui/tag/TagModel.h" #include "keeshare/KeeShare.h" #ifdef WITH_XC_NETWORKING @@ -65,6 +66,7 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) , m_db(std::move(db)) , m_mainWidget(new QWidget(this)) , m_mainSplitter(new QSplitter(m_mainWidget)) + , m_groupSplitter(new QSplitter(this)) , m_messageWidget(new MessageWidget(this)) , m_previewView(new EntryPreviewWidget(this)) , m_previewSplitter(new QSplitter(m_mainWidget)) @@ -79,7 +81,8 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) , m_databaseOpenWidget(new DatabaseOpenWidget(this)) , m_keepass1OpenWidget(new KeePass1OpenWidget(this)) , m_opVaultOpenWidget(new OpVaultOpenWidget(this)) - , m_groupView(new GroupView(m_db.data(), m_mainSplitter)) + , m_groupView(new GroupView(m_db.data(), this)) + , m_tagView(new QListView(this)) , m_saveAttempts(0) , m_entrySearcher(new EntrySearcher(false)) { @@ -87,26 +90,51 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) m_messageWidget->setHidden(true); - auto* mainLayout = new QVBoxLayout(); + auto mainLayout = new QVBoxLayout(); mainLayout->addWidget(m_messageWidget); - auto* hbox = new QHBoxLayout(); + auto hbox = new QHBoxLayout(); mainLayout->addLayout(hbox); hbox->addWidget(m_mainSplitter); m_mainWidget->setLayout(mainLayout); - auto* rightHandSideWidget = new QWidget(m_mainSplitter); - auto* vbox = new QVBoxLayout(); - vbox->setMargin(0); - vbox->addWidget(m_searchingLabel); + // Setup tags view and place under groups + auto tagModel = new TagModel(m_db); + m_tagView->setModel(tagModel); + m_tagView->setFrameStyle(QFrame::NoFrame); + m_tagView->setSelectionMode(QListView::SingleSelection); + m_tagView->setSelectionBehavior(QListView::SelectRows); + m_tagView->setCurrentIndex(tagModel->index(0)); + connect(m_tagView, SIGNAL(activated(QModelIndex)), this, SLOT(filterByTag(QModelIndex))); + connect(m_tagView, SIGNAL(clicked(QModelIndex)), this, SLOT(filterByTag(QModelIndex))); + + auto tagsWidget = new QWidget(); + auto tagsLayout = new QVBoxLayout(); + auto tagsTitle = new QLabel(tr("Database Tags")); + tagsTitle->setProperty("title", true); + tagsWidget->setLayout(tagsLayout); + tagsLayout->addWidget(tagsTitle); + tagsLayout->addWidget(m_tagView); + + m_groupSplitter->setOrientation(Qt::Vertical); + m_groupSplitter->setChildrenCollapsible(true); + m_groupSplitter->addWidget(m_groupView); + m_groupSplitter->addWidget(tagsWidget); + m_groupSplitter->setStretchFactor(0, 70); + m_groupSplitter->setStretchFactor(1, 30); + + auto rightHandSideWidget = new QWidget(m_mainSplitter); + auto rightHandSideVBox = new QVBoxLayout(); + rightHandSideVBox->setMargin(0); + rightHandSideVBox->addWidget(m_searchingLabel); #ifdef WITH_XC_KEESHARE - vbox->addWidget(m_shareLabel); + rightHandSideVBox->addWidget(m_shareLabel); #endif - vbox->addWidget(m_previewSplitter); - rightHandSideWidget->setLayout(vbox); + rightHandSideVBox->addWidget(m_previewSplitter); + rightHandSideWidget->setLayout(rightHandSideVBox); m_entryView = new EntryView(rightHandSideWidget); m_mainSplitter->setChildrenCollapsible(false); - m_mainSplitter->addWidget(m_groupView); + m_mainSplitter->addWidget(m_groupSplitter); m_mainSplitter->addWidget(rightHandSideWidget); m_mainSplitter->setStretchFactor(0, 30); m_mainSplitter->setStretchFactor(1, 70); @@ -165,8 +193,9 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) addChildWidget(m_opVaultOpenWidget); // clang-format off - connect(m_mainSplitter, SIGNAL(splitterMoved(int,int)), SIGNAL(mainSplitterSizesChanged())); - connect(m_previewSplitter, SIGNAL(splitterMoved(int,int)), SIGNAL(previewSplitterSizesChanged())); + connect(m_mainSplitter, SIGNAL(splitterMoved(int,int)), SIGNAL(splitterSizesChanged())); + connect(m_groupSplitter, SIGNAL(splitterMoved(int,int)), SIGNAL(splitterSizesChanged())); + connect(m_previewSplitter, SIGNAL(splitterMoved(int,int)), SIGNAL(splitterSizesChanged())); connect(this, SIGNAL(currentModeChanged(DatabaseWidget::Mode)), m_previewView, SLOT(setDatabaseMode(DatabaseWidget::Mode))); connect(m_previewView, SIGNAL(errorOccurred(QString)), SLOT(showErrorMessage(QString))); connect(m_previewView, SIGNAL(entryUrlActivated(Entry*)), SLOT(openUrlForEntry(Entry*))); @@ -298,24 +327,34 @@ bool DatabaseWidget::isEditWidgetModified() const return false; } -QList DatabaseWidget::mainSplitterSizes() const +QHash> DatabaseWidget::splitterSizes() const { - return m_mainSplitter->sizes(); + return {{Config::GUI_SplitterState, m_mainSplitter->sizes()}, + {Config::GUI_PreviewSplitterState, m_previewSplitter->sizes()}, + {Config::GUI_GroupSplitterState, m_groupSplitter->sizes()}}; } -void DatabaseWidget::setMainSplitterSizes(const QList& sizes) +void DatabaseWidget::setSplitterSizes(const QHash>& sizes) { - m_mainSplitter->setSizes(sizes); -} - -QList DatabaseWidget::previewSplitterSizes() const -{ - return m_previewSplitter->sizes(); -} - -void DatabaseWidget::setPreviewSplitterSizes(const QList& sizes) -{ - m_previewSplitter->setSizes(sizes); + for (auto itr = sizes.constBegin(); itr != sizes.constEnd(); ++itr) { + // Less than two sizes indicates an invalid value + if (itr.value().size() < 2) { + continue; + } + switch (itr.key()) { + case Config::GUI_SplitterState: + m_mainSplitter->setSizes(itr.value()); + break; + case Config::GUI_PreviewSplitterState: + m_previewSplitter->setSizes(itr.value()); + break; + case Config::GUI_GroupSplitterState: + m_groupSplitter->setSizes(itr.value()); + break; + default: + break; + } + } } void DatabaseWidget::setSearchStringForAutoType(const QString& search) @@ -389,6 +428,8 @@ void DatabaseWidget::replaceDatabase(QSharedPointer db) m_db = std::move(db); connectDatabaseSignals(); m_groupView->changeDatabase(m_db); + auto tagModel = new TagModel(m_db); + m_tagView->setModel(tagModel); // Restore the new parent group pointer, if not found default to the root group // this prevents data loss when merging a database while creating a new entry @@ -646,6 +687,13 @@ void DatabaseWidget::copyAttribute(QAction* action) } } +void DatabaseWidget::filterByTag(const QModelIndex& index) +{ + m_tagView->selectionModel()->setCurrentIndex(index, QItemSelectionModel::Select); + const auto model = static_cast(m_tagView->model()); + emit requestSearch(model->data(index, Qt::UserRole).toString()); +} + void DatabaseWidget::showTotpKeyQrCode() { auto currentEntry = currentSelectedEntry(); @@ -1442,6 +1490,8 @@ void DatabaseWidget::endSearch() m_entryView->setFirstEntryActive(); // Enforce preview view update (prevents stale information if focus group is empty) m_previewView->setEntry(currentSelectedEntry()); + // Reset selection on tag view + m_tagView->selectionModel()->clearSelection(); } m_searchingLabel->setVisible(false); @@ -1512,9 +1562,12 @@ void DatabaseWidget::showEvent(QShowEvent* event) bool DatabaseWidget::focusNextPrevChild(bool next) { - // [parent] <-> GroupView <-> EntryView <-> EntryPreview <-> [parent] + // [parent] <-> GroupView <-> TagView <-> EntryView <-> EntryPreview <-> [parent] if (next) { if (m_groupView->hasFocus()) { + m_tagView->setFocus(); + return true; + } else if (m_tagView->hasFocus()) { m_entryView->setFocus(); return true; } else if (m_entryView->hasFocus()) { @@ -1526,6 +1579,9 @@ bool DatabaseWidget::focusNextPrevChild(bool next) m_entryView->setFocus(); return true; } else if (m_entryView->hasFocus()) { + m_tagView->setFocus(); + return true; + } else if (m_tagView->hasFocus()) { m_groupView->setFocus(); return true; } @@ -1926,6 +1982,7 @@ bool DatabaseWidget::performSave(QString& errorMessage, const QString& fileName) // Lock out interactions m_entryView->setDisabled(true); m_groupView->setDisabled(true); + m_tagView->setDisabled(true); QApplication::processEvents(); Database::SaveAction saveAction = Database::Atomic; @@ -1967,6 +2024,7 @@ bool DatabaseWidget::performSave(QString& errorMessage, const QString& fileName) // Return control m_entryView->setDisabled(false); m_groupView->setDisabled(false); + m_tagView->setDisabled(false); if (focusWidget) { focusWidget->setFocus(); diff --git a/src/gui/DatabaseWidget.h b/src/gui/DatabaseWidget.h index f804d0518..d77a38dd7 100644 --- a/src/gui/DatabaseWidget.h +++ b/src/gui/DatabaseWidget.h @@ -20,6 +20,7 @@ #define KEEPASSX_DATABASEWIDGET_H #include +#include #include #include "DatabaseOpenDialog.h" @@ -117,10 +118,8 @@ public: QByteArray entryViewState() const; bool setEntryViewState(const QByteArray& state) const; - QList mainSplitterSizes() const; - void setMainSplitterSizes(const QList& sizes); - QList previewSplitterSizes() const; - void setPreviewSplitterSizes(const QList& sizes); + QHash> splitterSizes() const; + void setSplitterSizes(const QHash>& sizes); void setSearchStringForAutoType(const QString& search); signals: @@ -148,11 +147,11 @@ signals: void listModeActivated(); void searchModeAboutToActivate(); void searchModeActivated(); - void mainSplitterSizesChanged(); - void previewSplitterSizesChanged(); + void splitterSizesChanged(); void entryViewStateChanged(); void clearSearch(); void requestGlobalAutoType(const QString& search); + void requestSearch(const QString& search); public slots: bool lock(); @@ -176,6 +175,7 @@ public slots: void copyURL(); void copyNotes(); void copyAttribute(QAction* action); + void filterByTag(const QModelIndex& index); void showTotp(); void showTotpKeyQrCode(); void copyTotp(); @@ -267,6 +267,7 @@ private: QPointer m_mainWidget; QPointer m_mainSplitter; + QPointer m_groupSplitter; QPointer m_messageWidget; QPointer m_previewView; QPointer m_previewSplitter; @@ -282,6 +283,7 @@ private: QPointer m_keepass1OpenWidget; QPointer m_opVaultOpenWidget; QPointer m_groupView; + QPointer m_tagView; QPointer m_entryView; QScopedPointer m_newGroup; diff --git a/src/gui/DatabaseWidgetStateSync.cpp b/src/gui/DatabaseWidgetStateSync.cpp index 50c77374c..9cc22254f 100644 --- a/src/gui/DatabaseWidgetStateSync.cpp +++ b/src/gui/DatabaseWidgetStateSync.cpp @@ -26,8 +26,10 @@ DatabaseWidgetStateSync::DatabaseWidgetStateSync(QObject* parent) , m_activeDbWidget(nullptr) , m_blockUpdates(false) { - m_mainSplitterSizes = variantToIntList(config()->get(Config::GUI_SplitterState)); - m_previewSplitterSizes = variantToIntList(config()->get(Config::GUI_PreviewSplitterState)); + m_splitterSizes = { + {Config::GUI_SplitterState, variantToIntList(config()->get(Config::GUI_SplitterState))}, + {Config::GUI_PreviewSplitterState, variantToIntList(config()->get(Config::GUI_PreviewSplitterState))}, + {Config::GUI_GroupSplitterState, variantToIntList(config()->get(Config::GUI_GroupSplitterState))}}; m_listViewState = config()->get(Config::GUI_ListViewState).toByteArray(); m_searchViewState = config()->get(Config::GUI_SearchViewState).toByteArray(); @@ -43,8 +45,11 @@ DatabaseWidgetStateSync::~DatabaseWidgetStateSync() */ void DatabaseWidgetStateSync::sync() { - config()->set(Config::GUI_SplitterState, intListToVariant(m_mainSplitterSizes)); - config()->set(Config::GUI_PreviewSplitterState, intListToVariant(m_previewSplitterSizes)); + config()->set(Config::GUI_SplitterState, intListToVariant(m_splitterSizes.value(Config::GUI_SplitterState))); + config()->set(Config::GUI_PreviewSplitterState, + intListToVariant(m_splitterSizes.value(Config::GUI_PreviewSplitterState))); + config()->set(Config::GUI_GroupSplitterState, + intListToVariant(m_splitterSizes.value(Config::GUI_GroupSplitterState))); config()->set(Config::GUI_ListViewState, m_listViewState); config()->set(Config::GUI_SearchViewState, m_searchViewState); config()->sync(); @@ -61,13 +66,7 @@ void DatabaseWidgetStateSync::setActive(DatabaseWidget* dbWidget) if (m_activeDbWidget) { m_blockUpdates = true; - if (!m_mainSplitterSizes.isEmpty()) { - m_activeDbWidget->setMainSplitterSizes(m_mainSplitterSizes); - } - - if (!m_previewSplitterSizes.isEmpty()) { - m_activeDbWidget->setPreviewSplitterSizes(m_previewSplitterSizes); - } + m_activeDbWidget->setSplitterSizes(m_splitterSizes); if (m_activeDbWidget->isSearchActive()) { restoreSearchView(); @@ -77,8 +76,7 @@ void DatabaseWidgetStateSync::setActive(DatabaseWidget* dbWidget) m_blockUpdates = false; - connect(m_activeDbWidget, SIGNAL(mainSplitterSizesChanged()), SLOT(updateSplitterSizes())); - connect(m_activeDbWidget, SIGNAL(previewSplitterSizesChanged()), SLOT(updateSplitterSizes())); + connect(m_activeDbWidget, SIGNAL(splitterSizesChanged()), SLOT(updateSplitterSizes())); connect(m_activeDbWidget, SIGNAL(entryViewStateChanged()), SLOT(updateViewState())); connect(m_activeDbWidget, SIGNAL(listModeActivated()), SLOT(restoreListView())); connect(m_activeDbWidget, SIGNAL(searchModeActivated()), SLOT(restoreSearchView())); @@ -138,12 +136,9 @@ void DatabaseWidgetStateSync::blockUpdates() void DatabaseWidgetStateSync::updateSplitterSizes() { - if (m_blockUpdates) { - return; + if (!m_blockUpdates) { + m_splitterSizes = m_activeDbWidget->splitterSizes(); } - - m_mainSplitterSizes = m_activeDbWidget->mainSplitterSizes(); - m_previewSplitterSizes = m_activeDbWidget->previewSplitterSizes(); } /** diff --git a/src/gui/DatabaseWidgetStateSync.h b/src/gui/DatabaseWidgetStateSync.h index 4e28b0a43..8f8aef6dc 100644 --- a/src/gui/DatabaseWidgetStateSync.h +++ b/src/gui/DatabaseWidgetStateSync.h @@ -48,8 +48,7 @@ private: QPointer m_activeDbWidget; bool m_blockUpdates; - QList m_mainSplitterSizes; - QList m_previewSplitterSizes; + QHash> m_splitterSizes; QByteArray m_listViewState; QByteArray m_searchViewState; diff --git a/src/gui/EntryPreviewWidget.cpp b/src/gui/EntryPreviewWidget.cpp index 7c3b8ffd2..6fc6e1992 100644 --- a/src/gui/EntryPreviewWidget.cpp +++ b/src/gui/EntryPreviewWidget.cpp @@ -284,6 +284,8 @@ void EntryPreviewWidget::updateEntryGeneralTab() const QString expires = entryTime.expires() ? entryTime.expiryTime().toLocalTime().toString(Qt::DefaultLocaleShortDate) : tr("Never"); m_ui->entryExpirationLabel->setText(expires); + m_ui->entryTagsList->tags(m_currentEntry->tagList()); + m_ui->entryTagsList->setReadOnly(true); } void EntryPreviewWidget::updateEntryAdvancedTab() diff --git a/src/gui/EntryPreviewWidget.ui b/src/gui/EntryPreviewWidget.ui index 1aa1ae364..d1dc4a2f5 100644 --- a/src/gui/EntryPreviewWidget.ui +++ b/src/gui/EntryPreviewWidget.ui @@ -6,8 +6,8 @@ 0 0 - 566 - 247 + 596 + 261 @@ -171,7 +171,7 @@ - 1 + 0 false @@ -386,6 +386,35 @@ + + + + + 0 + 0 + + + + + 75 + true + + + + Tags + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Tags list + + + @@ -418,7 +447,7 @@ - + 6 @@ -1183,6 +1212,11 @@ QLabel
gui/widgets/ElidedLabel.h
+ + TagsEdit + QWidget +
gui/tag/TagsEdit.h
+
entryTotpButton diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 4d88b6e68..3a7af1751 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -1337,7 +1337,11 @@ bool MainWindow::focusNextPrevChild(bool next) // Search Widget <-> Tab Widget <-> DbWidget if (next) { if (m_searchWidget->hasFocus()) { - m_ui->tabWidget->setFocus(Qt::TabFocusReason); + if (m_ui->tabWidget->count() > 1) { + m_ui->tabWidget->setFocus(Qt::TabFocusReason); + } else { + dbWidget->setFocus(Qt::TabFocusReason); + } } else if (m_ui->tabWidget->hasFocus()) { dbWidget->setFocus(Qt::TabFocusReason); } else { @@ -1349,7 +1353,11 @@ bool MainWindow::focusNextPrevChild(bool next) } else if (m_ui->tabWidget->hasFocus()) { focusSearchWidget(); } else { - m_ui->tabWidget->setFocus(Qt::BacktabFocusReason); + if (m_ui->tabWidget->count() > 1) { + m_ui->tabWidget->setFocus(Qt::BacktabFocusReason); + } else { + focusSearchWidget(); + } } } return true; diff --git a/src/gui/SearchWidget.cpp b/src/gui/SearchWidget.cpp index 21282e269..ab79868aa 100644 --- a/src/gui/SearchWidget.cpp +++ b/src/gui/SearchWidget.cpp @@ -130,6 +130,7 @@ void SearchWidget::connectSignals(SignalMultiplexer& mx) mx.connect(this, SIGNAL(limitGroupChanged(bool)), SLOT(setSearchLimitGroup(bool))); mx.connect(this, SIGNAL(copyPressed()), SLOT(copyPassword())); mx.connect(this, SIGNAL(downPressed()), SLOT(focusOnEntries())); + mx.connect(SIGNAL(requestSearch(QString)), m_ui->searchEdit, SLOT(setText(QString))); mx.connect(SIGNAL(clearSearch()), this, SLOT(clearSearch())); mx.connect(SIGNAL(entrySelectionChanged()), this, SLOT(resetSearchClearTimer())); mx.connect(SIGNAL(currentModeChanged(DatabaseWidget::Mode)), this, SLOT(resetSearchClearTimer())); diff --git a/src/gui/entry/EditEntryWidget.cpp b/src/gui/entry/EditEntryWidget.cpp index 78d8b48cd..32d0b8225 100644 --- a/src/gui/entry/EditEntryWidget.cpp +++ b/src/gui/entry/EditEntryWidget.cpp @@ -855,6 +855,8 @@ void EditEntryWidget::setForms(Entry* entry, bool restore) m_mainUi->usernameComboBox->lineEdit()->setReadOnly(m_history); m_mainUi->urlEdit->setReadOnly(m_history); m_mainUi->passwordEdit->setReadOnly(m_history); + m_mainUi->tagsList->tags(entry->tagList()); + m_mainUi->tagsList->completion(m_db->tagList()); m_mainUi->expireCheck->setEnabled(!m_history); m_mainUi->expireDatePicker->setReadOnly(m_history); m_mainUi->notesEnabled->setChecked(!config()->get(Config::Security_HideNotes).toBool()); @@ -1160,6 +1162,7 @@ void EditEntryWidget::updateEntryData(Entry* entry) const entry->setPassword(m_mainUi->passwordEdit->text()); entry->setExpires(m_mainUi->expireCheck->isChecked()); entry->setExpiryTime(m_mainUi->expireDatePicker->dateTime().toUTC()); + entry->setTags(m_mainUi->tagsList->tags().toSet().toList().join(";")); // remove repeated tags entry->setNotes(m_mainUi->notesEdit->toPlainText()); diff --git a/src/gui/entry/EditEntryWidgetMain.ui b/src/gui/entry/EditEntryWidgetMain.ui index 07445261e..d0afa4bfd 100644 --- a/src/gui/entry/EditEntryWidgetMain.ui +++ b/src/gui/entry/EditEntryWidgetMain.ui @@ -56,7 +56,7 @@ 8 - + @@ -99,7 +99,7 @@
- + @@ -129,7 +129,7 @@ - + 8 @@ -252,7 +252,7 @@ - + 0 @@ -272,6 +272,26 @@
+ + + + Tags: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Qt::StrongFocus + + + Tags list + + + @@ -288,12 +308,19 @@
gui/URLEdit.h
1 + + TagsEdit + QAbstractScrollArea +
gui/tag/TagsEdit.h
+ 1 +
titleEdit usernameComboBox passwordEdit urlEdit + tagsList fetchFaviconButton expireCheck expireDatePicker diff --git a/src/gui/styles/base/basestyle.qss b/src/gui/styles/base/basestyle.qss index e015efc25..8aee33b81 100644 --- a/src/gui/styles/base/basestyle.qss +++ b/src/gui/styles/base/basestyle.qss @@ -73,3 +73,7 @@ QPlainTextEdit, QTextEdit { QStatusBar { background-color: palette(window); } + +*[title="true"] { + font-weight: bold; +} diff --git a/src/gui/styles/base/classicstyle.qss b/src/gui/styles/base/classicstyle.qss index 2d856a3cf..8ee51cf11 100644 --- a/src/gui/styles/base/classicstyle.qss +++ b/src/gui/styles/base/classicstyle.qss @@ -17,3 +17,7 @@ DatabaseWidget #SearchBanner, DatabaseWidget #KeeShareBanner { QLineEdit { padding-left: 2px; } + +*[title="true"] { + font-weight: bold; +} diff --git a/src/gui/tag/TagModel.cpp b/src/gui/tag/TagModel.cpp new file mode 100644 index 000000000..023cb3498 --- /dev/null +++ b/src/gui/tag/TagModel.cpp @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2018 KeePassXC Team + * + * 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 or (at your option) + * 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 . + */ + +#include "TagModel.h" + +#include "core/Database.h" +#include "gui/Icons.h" + +TagModel::TagModel(QSharedPointer db, QObject* parent) + : QAbstractListModel(parent) +{ + setDatabase(db); +} + +TagModel::~TagModel() +{ +} + +void TagModel::setDatabase(QSharedPointer db) +{ + m_db = db; + if (!m_db) { + m_tagList.clear(); + return; + } + connect(m_db.data(), SIGNAL(tagListUpdated()), SLOT(updateTagList())); + updateTagList(); +} + +void TagModel::updateTagList() +{ + beginResetModel(); + m_tagList.clear(); + m_tagList << tr("All") << tr("Expired") << tr("Weak Passwords") << m_db->tagList(); + endResetModel(); +} + +int TagModel::rowCount(const QModelIndex& parent) const +{ + Q_UNUSED(parent); + return m_tagList.size(); +} + +QVariant TagModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() >= m_tagList.size()) { + return {}; + } + + switch (role) { + case Qt::DecorationRole: + if (index.row() <= 2) { + return icons()->icon("tag-search"); + } + return icons()->icon("tag"); + case Qt::DisplayRole: + return m_tagList.at(index.row()); + case Qt::UserRole: + if (index.row() == 0) { + return ""; + } else if (index.row() == 1) { + return "is:expired"; + } else if (index.row() == 2) { + return "is:weak"; + } + return QString("tag:%1").arg(m_tagList.at(index.row())); + } + + return {}; +} + +const QStringList& TagModel::tags() const +{ + return m_tagList; +} diff --git a/src/gui/tag/TagModel.h b/src/gui/tag/TagModel.h new file mode 100644 index 000000000..020f621f0 --- /dev/null +++ b/src/gui/tag/TagModel.h @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2018 KeePassXC Team + * + * 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 or (at your option) + * 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 . + */ + +#ifndef KEEPASSX_TAGMODEL_H +#define KEEPASSX_TAGMODEL_H + +#include +#include + +class Database; + +class TagModel : public QAbstractListModel +{ + Q_OBJECT + +public: + explicit TagModel(QSharedPointer db, QObject* parent = nullptr); + ~TagModel() override; + + void setDatabase(QSharedPointer db); + const QStringList& tags() const; + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + +private slots: + void updateTagList(); + +private: + QSharedPointer m_db; + QStringList m_tagList; +}; + +#endif // KEEPASSX_TAGMODEL_H diff --git a/src/gui/tag/TagsEdit.cpp b/src/gui/tag/TagsEdit.cpp new file mode 100644 index 000000000..b4bfb7f75 --- /dev/null +++ b/src/gui/tag/TagsEdit.cpp @@ -0,0 +1,963 @@ +/* + MIT License + + Copyright (c) 2021 Nicolai Trandafil + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +*/ + +#include "TagsEdit.h" +#include "gui/MainWindow.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#if QT_VERSION < QT_VERSION_CHECK(5, 11, 0) +#define FONT_METRICS_WIDTH(fmt, ...) fmt.width(__VA_ARGS__) +#else +#define FONT_METRICS_WIDTH(fmt, ...) fmt.horizontalAdvance(__VA_ARGS__) +#endif + +namespace +{ + + constexpr int tag_v_spacing = 2; + constexpr int tag_h_spacing = 3; + + constexpr QMargins tag_inner(5, 3, 4, 3); + + constexpr int tag_cross_width = 5; + constexpr float tag_cross_radius = tag_cross_width / 2; + constexpr int tag_cross_padding = 5; + + struct Tag + { + bool isEmpty() const noexcept + { + return text.isEmpty(); + } + + QString text; + QRect rect; + size_t row; + }; + + /// Non empty string filtering iterator + template struct EmptySkipIterator + { + EmptySkipIterator() = default; + + // skip until `end` + explicit EmptySkipIterator(It it, It end) + : it(it) + , end(end) + { + while (this->it != end && this->it->isEmpty()) { + ++this->it; + } + begin = it; + } + + explicit EmptySkipIterator(It it) + : it(it) + , end{} + { + } + + using difference_type = typename std::iterator_traits::difference_type; + using value_type = typename std::iterator_traits::value_type; + using pointer = typename std::iterator_traits::pointer; + using reference = typename std::iterator_traits::reference; + using iterator_category = std::output_iterator_tag; + + EmptySkipIterator& operator++() + { + assert(it != end); + while (++it != end && it->isEmpty()) + ; + return *this; + } + + decltype(auto) operator*() + { + return *it; + } + + pointer operator->() + { + return &(*it); + } + + bool operator!=(EmptySkipIterator const& rhs) const + { + return it != rhs.it; + } + + bool operator==(EmptySkipIterator const& rhs) const + { + return it == rhs.it; + } + + private: + It begin; + It it; + It end; + }; + + template EmptySkipIterator(It, It) -> EmptySkipIterator; + +} // namespace + +// Invariant-1 ensures no empty tags apart from currently being edited. +// Default-state is one empty tag which is currently editing. +struct TagsEdit::Impl +{ + explicit Impl(TagsEdit* ifce) + : ifce(ifce) + , tags{Tag()} + , editing_index(0) + , cursor(0) + , blink_timer(0) + , blink_status(true) + , select_start(0) + , select_size(0) + , cross_deleter(true) + , completer(std::make_unique()) + { + } + + inline QRectF crossRect(QRectF const& r) const + { + QRectF cross(QPointF{0, 0}, QSizeF{tag_cross_width + tag_cross_padding * 2, r.top() - r.bottom()}); + cross.moveCenter(QPointF(r.right() - tag_cross_radius - tag_cross_padding, r.center().y())); + return cross; + } + + bool inCrossArea(int tag_index, QPoint point) const + { + return cross_deleter + ? crossRect(tags[tag_index].rect) + .adjusted(-tag_cross_radius, 0, 0, 0) + .translated(-ifce->horizontalScrollBar()->value(), -ifce->verticalScrollBar()->value()) + .contains(point) + && (!cursorVisible() || tag_index != editing_index) + : false; + } + + template void drawTags(QPainter& p, std::pair range) const + { + for (auto it = range.first; it != range.second; ++it) { + QRect const& i_r = + it->rect.translated(-ifce->horizontalScrollBar()->value(), -ifce->verticalScrollBar()->value()); + auto const text_pos = + i_r.topLeft() + + QPointF(tag_inner.left(), + ifce->fontMetrics().ascent() + ((i_r.height() - ifce->fontMetrics().height()) / 2)); + + // draw tag rect + auto palette = getMainWindow()->palette(); + QPainterPath path; + auto cornerRadius = 4; + path.addRoundedRect(i_r, cornerRadius, cornerRadius); + p.fillPath(path, palette.brush(QPalette::ColorGroup::Inactive, QPalette::ColorRole::Highlight)); + + // draw text + p.drawText(text_pos, it->text); + + if (cross_deleter) { + // calc cross rect + auto const i_cross_r = crossRect(i_r); + + QPainterPath crossRectBg1, crossRectBg2; + crossRectBg1.addRoundedRect(i_cross_r, cornerRadius, cornerRadius); + // cover left rounded corners + crossRectBg2.addRect( + i_cross_r.left(), i_cross_r.bottom(), tag_cross_radius, i_cross_r.top() - i_cross_r.bottom()); + p.fillPath(crossRectBg1, palette.highlight()); + p.fillPath(crossRectBg2, palette.highlight()); + + QPen pen = p.pen(); + pen.setWidth(2); + pen.setBrush(palette.highlightedText()); + + p.save(); + p.setPen(pen); + p.setRenderHint(QPainter::Antialiasing); + p.drawLine(QLineF(i_cross_r.center() - QPointF(tag_cross_radius, tag_cross_radius), + i_cross_r.center() + QPointF(tag_cross_radius, tag_cross_radius))); + p.drawLine(QLineF(i_cross_r.center() - QPointF(-tag_cross_radius, tag_cross_radius), + i_cross_r.center() + QPointF(-tag_cross_radius, tag_cross_radius))); + p.restore(); + } + } + } + + QRect contentsRect() const + { + return ifce->viewport()->contentsRect(); + } + + QRect calcRects(QList& tags) const + { + return calcRects(tags, contentsRect()); + } + + QRect calcRects(QList& tags, QRect r) const + { + size_t row = 0; + auto lt = r.topLeft(); + QFontMetrics fm = ifce->fontMetrics(); + + auto const b = std::begin(tags); + auto const e = std::end(tags); + if (cursorVisible()) { + auto const m = b + static_cast(editing_index); + calcRects(lt, row, r, fm, std::make_pair(b, m)); + calcEditorRect(lt, row, r, fm, m); + calcRects(lt, row, r, fm, std::make_pair(m + 1, e)); + } else { + calcRects(lt, row, r, fm, std::make_pair(b, e)); + } + + r.setBottom(lt.y() + fm.height() + fm.leading() + tag_inner.top() + tag_inner.bottom() - 1); + return r; + } + + template + void calcRects(QPoint& lt, size_t& row, QRect r, QFontMetrics const& fm, std::pair range) const + { + for (auto it = range.first; it != range.second; ++it) { + // calc text rect + const auto text_w = FONT_METRICS_WIDTH(fm, it->text); + auto const text_h = fm.height() + fm.leading(); + auto const w = cross_deleter + ? tag_inner.left() + tag_inner.right() + tag_cross_padding * 2 + tag_cross_width + : tag_inner.left() + tag_inner.right(); + auto const h = tag_inner.top() + tag_inner.bottom(); + QRect i_r(lt, QSize(text_w + w, text_h + h)); + + // line wrapping + if (r.right() < i_r.right() && // doesn't fit in current line + i_r.left() != r.left() // doesn't occupy entire line already + ) { + i_r.moveTo(r.left(), i_r.bottom() + tag_v_spacing); + ++row; + lt = i_r.topLeft(); + } + + it->rect = i_r; + it->row = row; + lt.setX(i_r.right() + tag_h_spacing); + } + } + + template void calcEditorRect(QPoint& lt, size_t& row, QRect r, QFontMetrics const& fm, It it) const + { + auto const text_w = FONT_METRICS_WIDTH(fm, text_layout.text()); + auto const text_h = fm.height() + fm.leading(); + auto const w = tag_inner.left() + tag_inner.right(); + auto const h = tag_inner.top() + tag_inner.bottom(); + QRect i_r(lt, QSize(text_w + w, text_h + h)); + + // line wrapping + if (r.right() < i_r.right() && // doesn't fit in current line + i_r.left() != r.left() // doesn't occupy entire line already + ) { + i_r.moveTo(r.left(), i_r.bottom() + tag_v_spacing); + ++row; + lt = i_r.topLeft(); + } + + it->rect = i_r; + it->row = row; + lt.setX(i_r.right() + tag_h_spacing); + } + + void setCursorVisible(bool visible) + { + if (blink_timer) { + ifce->killTimer(blink_timer); + blink_timer = 0; + blink_status = true; + } + + if (visible) { + int flashTime = QGuiApplication::styleHints()->cursorFlashTime(); + if (flashTime >= 2) { + blink_timer = ifce->startTimer(flashTime / 2); + } + } else { + blink_status = false; + } + } + + bool cursorVisible() const + { + return blink_timer; + } + + void updateCursorBlinking() + { + setCursorVisible(cursorVisible()); + } + + void updateDisplayText() + { + text_layout.clearLayout(); + text_layout.setText(currentText()); + text_layout.beginLayout(); + text_layout.createLine(); + text_layout.endLayout(); + } + + /// Makes the tag at `i` currently editing, and ensures Invariant-1`. + void setEditingIndex(int i) + { + assert(i < tags.size()); + auto occurrencesOfCurrentText = + std::count_if(tags.cbegin(), tags.cend(), [this](const auto& tag) { return tag.text == currentText(); }); + if (currentText().isEmpty() || occurrencesOfCurrentText > 1) { + tags.erase(std::next(tags.begin(), std::ptrdiff_t(editing_index))); + if (editing_index <= i) { // Do we shift positions after `i`? + --i; + } + } + editing_index = i; + } + + void calcRectsAndUpdateScrollRanges() + { + auto const row = tags.back().row; + auto const max_width = std::max_element(std::begin(tags), std::end(tags), [](auto const& x, auto const& y) { + return x.rect.width() < y.rect.width(); + })->rect.width(); + + calcRects(tags); + + if (row != tags.back().row) { + updateVScrollRange(); + } + + auto const new_max_width = std::max_element(std::begin(tags), std::end(tags), [](auto const& x, auto const& y) { + return x.rect.width() < y.rect.width(); + })->rect.width(); + + if (max_width != new_max_width) { + updateHScrollRange(new_max_width); + } + } + + void currentText(QString const& text) + { + currentText() = text; + moveCursor(currentText().length(), false); + updateDisplayText(); + calcRectsAndUpdateScrollRanges(); + ifce->viewport()->update(); + } + + QString const& currentText() const + { + return tags[editing_index].text; + } + + QString& currentText() + { + return tags[editing_index].text; + } + + QRect const& currentRect() const + { + return tags[editing_index].rect; + } + + // Inserts a new tag at `i`, makes the tag currently editing, + // and ensures Invariant-1. + void editNewTag(int i) + { + tags.insert(std::next(std::begin(tags), static_cast(i)), Tag()); + if (editing_index >= i) { + ++editing_index; + } + setEditingIndex(i); + moveCursor(0, false); + } + + void setupCompleter() + { + completer->setWidget(ifce); + connect(completer.get(), qOverload(&QCompleter::activated), [this](QString const& text) { + currentText(text); + }); + } + + QVector formatting() const + { + if (select_size == 0) { + return {}; + } + + QTextLayout::FormatRange selection; + selection.start = select_start; + selection.length = select_size; + selection.format.setBackground(ifce->palette().brush(QPalette::Highlight)); + selection.format.setForeground(ifce->palette().brush(QPalette::HighlightedText)); + return {selection}; + } + + bool hasSelection() const noexcept + { + return select_size > 0; + } + + void removeSelection() + { + cursor = select_start; + currentText().remove(cursor, select_size); + deselectAll(); + } + + void removeBackwardOne() + { + if (hasSelection()) { + removeSelection(); + } else { + currentText().remove(--cursor, 1); + } + } + + void selectAll() + { + select_start = 0; + select_size = currentText().size(); + } + + void deselectAll() + { + select_start = 0; + select_size = 0; + } + + void moveCursor(int pos, bool mark) + { + if (mark) { + auto e = select_start + select_size; + int anchor = select_size > 0 && cursor == select_start ? e + : select_size > 0 && cursor == e ? select_start + : cursor; + select_start = qMin(anchor, pos); + select_size = qMax(anchor, pos) - select_start; + } else { + deselectAll(); + } + + cursor = pos; + } + + qreal cursorToX() + { + return text_layout.lineAt(0).cursorToX(cursor); + } + + void editPreviousTag() + { + if (editing_index > 0) { + setEditingIndex(editing_index - 1); + moveCursor(currentText().size(), false); + } + } + + void editNextTag() + { + if (editing_index < tags.size() - 1) { + setEditingIndex(editing_index + 1); + moveCursor(0, false); + } + } + + void editTag(int i) + { + assert(i >= 0 && i < tags.size()); + setEditingIndex(i); + moveCursor(currentText().size(), false); + } + + void updateVScrollRange() + { + auto fm = ifce->fontMetrics(); + auto const row_h = fm.height() + fm.leading() + tag_inner.top() + tag_inner.bottom() + tag_v_spacing; + ifce->verticalScrollBar()->setPageStep(row_h); + auto const h = tags.back().rect.bottom() - tags.front().rect.top() + 1; + auto const contents_rect = contentsRect(); + if (h > contents_rect.height()) { + ifce->verticalScrollBar()->setRange(0, h - contents_rect.height()); + } else { + ifce->verticalScrollBar()->setRange(0, 0); + } + } + + void updateHScrollRange() + { + auto const max_width = std::max_element(std::begin(tags), std::end(tags), [](auto const& x, auto const& y) { + return x.rect.width() < y.rect.width(); + })->rect.width(); + updateHScrollRange(max_width); + } + + void updateHScrollRange(int width) + { + auto const contents_rect_width = contentsRect().width(); + if (width > contents_rect_width) { + ifce->horizontalScrollBar()->setRange(0, width - contents_rect_width); + } else { + ifce->horizontalScrollBar()->setRange(0, 0); + } + } + + void ensureCursorIsVisibleV() + { + auto fm = ifce->fontMetrics(); + auto const row_h = fm.height() + fm.leading() + tag_inner.top() + tag_inner.bottom(); + auto const vscroll = ifce->verticalScrollBar()->value(); + auto const cursor_top = currentRect().topLeft() + QPoint(qRound(cursorToX()), 0); + auto const cursor_bottom = cursor_top + QPoint(0, row_h - 1); + auto const contents_rect = contentsRect().translated(0, vscroll); + if (contents_rect.bottom() < cursor_bottom.y()) { + ifce->verticalScrollBar()->setValue(cursor_bottom.y() - row_h); + } else if (cursor_top.y() < contents_rect.top()) { + ifce->verticalScrollBar()->setValue(cursor_top.y() - 1); + } + } + + void ensureCursorIsVisibleH() + { + auto const hscroll = ifce->horizontalScrollBar()->value(); + auto const contents_rect = contentsRect().translated(hscroll, 0); + auto const cursor_x = (currentRect() - tag_inner).left() + qRound(cursorToX()); + if (contents_rect.right() < cursor_x) { + ifce->horizontalScrollBar()->setValue(cursor_x - contents_rect.width()); + } else if (cursor_x < contents_rect.left()) { + ifce->horizontalScrollBar()->setValue(cursor_x - 1); + } + } + + TagsEdit* const ifce; + QList tags; + int editing_index; + int cursor; + int blink_timer; + bool blink_status; + QTextLayout text_layout; + int select_start; + int select_size; + bool cross_deleter; + std::unique_ptr completer; + int hscroll{0}; +}; + +TagsEdit::TagsEdit(QWidget* parent) + : QAbstractScrollArea(parent) + , impl(std::make_unique(this)) + , m_readOnly(false) +{ + QSizePolicy size_policy(QSizePolicy::Ignored, QSizePolicy::Preferred); + size_policy.setHeightForWidth(true); + setSizePolicy(size_policy); + + setFocusPolicy(Qt::StrongFocus); + viewport()->setCursor(Qt::IBeamCursor); + setAttribute(Qt::WA_InputMethodEnabled, true); + setMouseTracking(true); + + impl->setupCompleter(); + impl->setCursorVisible(hasFocus()); + impl->updateDisplayText(); + + viewport()->setContentsMargins(1, 1, 1, 1); +} + +TagsEdit::~TagsEdit() = default; + +void TagsEdit::setReadOnly(bool readOnly) +{ + m_readOnly = readOnly; + if (m_readOnly) { + setFocusPolicy(Qt::NoFocus); + setCursor(Qt::ArrowCursor); + setAttribute(Qt::WA_InputMethodEnabled, false); + setFrameShape(QFrame::NoFrame); + impl->cross_deleter = false; + } else { + setFocusPolicy(Qt::StrongFocus); + setCursor(Qt::IBeamCursor); + setAttribute(Qt::WA_InputMethodEnabled, true); + impl->cross_deleter = true; + } +} + +void TagsEdit::resizeEvent(QResizeEvent*) +{ + impl->calcRects(impl->tags); + impl->updateVScrollRange(); + impl->updateHScrollRange(); +} + +void TagsEdit::focusInEvent(QFocusEvent*) +{ + impl->setCursorVisible(true); + impl->updateDisplayText(); + impl->calcRects(impl->tags); + impl->completer->complete(); + viewport()->update(); +} + +void TagsEdit::focusOutEvent(QFocusEvent*) +{ + impl->setCursorVisible(false); + impl->updateDisplayText(); + impl->calcRects(impl->tags); + impl->completer->popup()->hide(); + viewport()->update(); +} + +void TagsEdit::paintEvent(QPaintEvent*) +{ + QPainter p(viewport()); + + // clip + auto const rect = impl->contentsRect(); + p.setClipRect(rect); + if (impl->cursorVisible()) { + // not terminated tag pos + auto const& r = impl->currentRect(); + auto const& txt_p = r.topLeft() + QPointF(tag_inner.left(), ((r.height() - fontMetrics().height()) / 2)); + + // tags + impl->drawTags( + p, + std::make_pair(impl->tags.cbegin(), std::next(impl->tags.cbegin(), std::ptrdiff_t(impl->editing_index)))); + + // draw not terminated tag + auto const formatting = impl->formatting(); + impl->text_layout.draw( + &p, txt_p - QPointF(horizontalScrollBar()->value(), verticalScrollBar()->value()), formatting); + + // draw cursor + if (impl->blink_status) { + impl->text_layout.drawCursor( + &p, txt_p - QPointF(horizontalScrollBar()->value(), verticalScrollBar()->value()), impl->cursor); + } + + // tags + impl->drawTags( + p, + std::make_pair(std::next(impl->tags.cbegin(), std::ptrdiff_t(impl->editing_index + 1)), impl->tags.cend())); + } else { + impl->drawTags(p, + std::make_pair(EmptySkipIterator(impl->tags.begin(), impl->tags.end()), + EmptySkipIterator(impl->tags.end()))); + } +} + +void TagsEdit::timerEvent(QTimerEvent* event) +{ + if (event->timerId() == impl->blink_timer) { + impl->blink_status = !impl->blink_status; + viewport()->update(); + } +} + +void TagsEdit::mousePressEvent(QMouseEvent* event) +{ + bool found = false; + for (int i = 0; i < impl->tags.size(); ++i) { + if (impl->inCrossArea(i, event->pos())) { + impl->tags.erase(impl->tags.begin() + std::ptrdiff_t(i)); + if (i <= impl->editing_index) { + --impl->editing_index; + } + found = true; + break; + } + + if (!impl->tags[i] + .rect.translated(-horizontalScrollBar()->value(), -verticalScrollBar()->value()) + .contains(event->pos())) { + continue; + } + + if (impl->editing_index == i) { + impl->moveCursor(impl->text_layout.lineAt(0).xToCursor( + (event->pos() + - impl->currentRect() + .translated(-horizontalScrollBar()->value(), -verticalScrollBar()->value()) + .topLeft()) + .x()), + false); + } else { + impl->editTag(i); + } + + found = true; + break; + } + + if (!found) { + for (auto it = std::begin(impl->tags); it != std::end(impl->tags); ++it) { + // Click of a row. + if (it->rect.translated(-horizontalScrollBar()->value(), -verticalScrollBar()->value()).bottom() + < event->pos().y()) { + continue; + } + + // Last tag of the row. + auto const row = it->row; + while (it != std::end(impl->tags) && it->row == row) { + ++it; + } + + impl->editNewTag(static_cast(std::distance(std::begin(impl->tags), it))); + break; + } + + event->accept(); + } + + if (event->isAccepted()) { + impl->updateDisplayText(); + impl->calcRectsAndUpdateScrollRanges(); + impl->ensureCursorIsVisibleV(); + impl->ensureCursorIsVisibleH(); + impl->updateCursorBlinking(); + viewport()->update(); + } +} + +QSize TagsEdit::sizeHint() const +{ + return minimumSizeHint(); +} + +QSize TagsEdit::minimumSizeHint() const +{ + ensurePolished(); + QFontMetrics fm = fontMetrics(); + QRect rect(0, 0, fm.maxWidth() + tag_cross_padding + tag_cross_width, fm.height() + fm.leading()); + rect += tag_inner + contentsMargins() + viewport()->contentsMargins() + viewportMargins(); + return rect.size(); +} + +int TagsEdit::heightForWidth(int w) const +{ + auto const content_width = w; + QRect contents_rect(0, 0, content_width, 100); + contents_rect -= contentsMargins() + viewport()->contentsMargins() + viewportMargins(); + auto tags = impl->tags; + contents_rect = impl->calcRects(tags, contents_rect); + contents_rect += contentsMargins() + viewport()->contentsMargins() + viewportMargins(); + return contents_rect.height(); +} + +void TagsEdit::keyPressEvent(QKeyEvent* event) +{ + event->setAccepted(false); + bool unknown = false; + + if (event == QKeySequence::SelectAll) { + impl->selectAll(); + event->accept(); + } else if (event == QKeySequence::SelectPreviousChar) { + impl->moveCursor(impl->text_layout.previousCursorPosition(impl->cursor), true); + event->accept(); + } else if (event == QKeySequence::SelectNextChar) { + impl->moveCursor(impl->text_layout.nextCursorPosition(impl->cursor), true); + event->accept(); + } else { + switch (event->key()) { + case Qt::Key_Left: + if (impl->cursor == 0) { + impl->editPreviousTag(); + } else { + impl->moveCursor(impl->text_layout.previousCursorPosition(impl->cursor), false); + } + event->accept(); + break; + case Qt::Key_Right: + if (impl->cursor == impl->currentText().size()) { + impl->editNextTag(); + } else { + impl->moveCursor(impl->text_layout.nextCursorPosition(impl->cursor), false); + } + event->accept(); + break; + case Qt::Key_Home: + if (impl->cursor == 0) { + impl->editTag(0); + } else { + impl->moveCursor(0, false); + } + event->accept(); + break; + case Qt::Key_End: + if (impl->cursor == impl->currentText().size()) { + impl->editTag(impl->tags.size() - 1); + } else { + impl->moveCursor(impl->currentText().length(), false); + } + event->accept(); + break; + case Qt::Key_Backspace: + if (!impl->currentText().isEmpty()) { + impl->removeBackwardOne(); + } else if (impl->editing_index > 0) { + impl->editPreviousTag(); + } + event->accept(); + break; + case Qt::Key_Space: + if (!impl->currentText().isEmpty()) { + impl->editNewTag(impl->editing_index + 1); + } + event->accept(); + break; + default: + unknown = true; + } + } + + if (unknown && isAcceptableInput(event)) { + if (impl->hasSelection()) { + impl->removeSelection(); + } + impl->currentText().insert(impl->cursor, event->text()); + impl->cursor = impl->cursor + event->text().length(); + event->accept(); + } + + if (event->isAccepted()) { + // update content + impl->updateDisplayText(); + impl->calcRectsAndUpdateScrollRanges(); + impl->ensureCursorIsVisibleV(); + impl->ensureCursorIsVisibleH(); + impl->updateCursorBlinking(); + + // complete + impl->completer->setCompletionPrefix(impl->currentText()); + impl->completer->complete(); + + viewport()->update(); + + emit tagsEdited(); + } +} + +void TagsEdit::completion(QStringList const& completions) +{ + impl->completer = std::make_unique([&] { + QStringList ret; + std::copy(completions.begin(), completions.end(), std::back_inserter(ret)); + return ret; + }()); + impl->setupCompleter(); +} + +void TagsEdit::tags(QStringList const& tags) +{ + // Set to Default-state. + impl->editing_index = 0; + QList t{Tag()}; + + std::transform(EmptySkipIterator(tags.begin(), tags.end()), // Ensure Invariant-1 + EmptySkipIterator(tags.end()), + std::back_inserter(t), + [](QString const& text) { + return Tag{text, QRect(), 0}; + }); + + impl->tags = std::move(t); + impl->editNewTag(impl->tags.size()); + impl->updateDisplayText(); + impl->calcRectsAndUpdateScrollRanges(); + viewport()->update(); + updateGeometry(); +} + +QStringList TagsEdit::tags() const +{ + QStringList ret; + std::transform(EmptySkipIterator(impl->tags.begin(), impl->tags.end()), + EmptySkipIterator(impl->tags.end()), + std::back_inserter(ret), + [](Tag const& tag) { return tag.text; }); + return ret; +} + +void TagsEdit::mouseMoveEvent(QMouseEvent* event) +{ + if (!m_readOnly) { + for (int i = 0; i < impl->tags.size(); ++i) { + if (impl->inCrossArea(i, event->pos())) { + viewport()->setCursor(Qt::ArrowCursor); + return; + } + } + if (impl->contentsRect().contains(event->pos())) { + viewport()->setCursor(Qt::IBeamCursor); + } else { + QAbstractScrollArea::mouseMoveEvent(event); + } + } +} + +bool TagsEdit::isAcceptableInput(const QKeyEvent* event) const +{ + const QString text = event->text(); + if (text.isEmpty()) + return false; + + const QChar c = text.at(0); + + // Formatting characters such as ZWNJ, ZWJ, RLM, etc. This needs to go before the + // next test, since CTRL+SHIFT is sometimes used to input it on Windows. + if (c.category() == QChar::Other_Format) + return true; + + // QTBUG-35734: ignore Ctrl/Ctrl+Shift; accept only AltGr (Alt+Ctrl) on German keyboards + if (event->modifiers() == Qt::ControlModifier || event->modifiers() == (Qt::ShiftModifier | Qt::ControlModifier)) { + return false; + } + + if (c.isPrint()) + return true; + + if (c.category() == QChar::Other_PrivateUse) + return true; + + return false; +} diff --git a/src/gui/tag/TagsEdit.h b/src/gui/tag/TagsEdit.h new file mode 100644 index 000000000..6c2a974cb --- /dev/null +++ b/src/gui/tag/TagsEdit.h @@ -0,0 +1,78 @@ +/* + MIT License + + Copyright (c) 2019 Nicolai Trandafil + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +*/ + +#pragma once + +#include + +#include +#include + +/// Tag multi-line editor widget +/// `Space` commits a tag and initiates a new tag edition +class TagsEdit : public QAbstractScrollArea +{ + Q_OBJECT + +public: + explicit TagsEdit(QWidget* parent = nullptr); + ~TagsEdit() override; + + // QWidget + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + int heightForWidth(int w) const override; + + /// Set completions + void completion(QStringList const& completions); + + /// Set tags + void tags(QStringList const& tags); + + /// Get tags + QStringList tags() const; + + void setReadOnly(bool readOnly); + +signals: + void tagsEdited(); + +protected: + // QWidget + void paintEvent(QPaintEvent* event) override; + void timerEvent(QTimerEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + void focusInEvent(QFocusEvent* event) override; + void focusOutEvent(QFocusEvent* event) override; + void keyPressEvent(QKeyEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + +private: + bool isAcceptableInput(QKeyEvent const* event) const; + + struct Impl; + std::unique_ptr impl; + bool m_readOnly; +}; diff --git a/tests/gui/TestGui.cpp b/tests/gui/TestGui.cpp index 34b047e9b..06b3d301e 100644 --- a/tests/gui/TestGui.cpp +++ b/tests/gui/TestGui.cpp @@ -53,6 +53,7 @@ #include "gui/group/EditGroupWidget.h" #include "gui/group/GroupModel.h" #include "gui/group/GroupView.h" +#include "gui/tag/TagsEdit.h" #include "gui/wizard/NewDatabaseWizard.h" #include "keys/FileKey.h" @@ -447,6 +448,19 @@ void TestGui::testEditEntry() QCOMPARE(entry->historyItems().size(), ++editCount); QVERIFY(entry->excludeFromReports()); + // Test tags + auto* tags = editEntryWidget->findChild("tagsList"); + QTest::keyClicks(tags, "_tag1"); + QTest::keyClick(tags, Qt::Key_Space); + QCOMPARE(tags->tags().last(), QString("_tag1")); + QTest::keyClick(tags, Qt::Key_Space); + QTest::keyClicks(tags, "_tag2"); // adds another tag + QCOMPARE(tags->tags().last(), QString("_tag2")); + QTest::keyClick(tags, Qt::Key_Backspace); // Back into editing last tag + QTest::keyClicks(tags, "gers"); + QTest::keyClick(tags, Qt::Key_Space); + QCOMPARE(tags->tags().last(), QString("_taggers")); + // Test entry colors (simulate choosing a color) editEntryWidget->setCurrentPage(1); auto fgColor = QString("#FF0000"); @@ -872,6 +886,16 @@ void TestGui::testSearch() QTRY_VERIFY(m_dbWidget->isSearchActive()); QTRY_COMPARE(entryView->model()->rowCount(), 0); // Press the search clear button + searchTextEdit->clear(); + QTRY_VERIFY(searchTextEdit->text().isEmpty()); + QTRY_VERIFY(searchTextEdit->hasFocus()); + + // Test tag search + searchTextEdit->clear(); + QTest::keyClicks(searchTextEdit, "tag: testTag"); + QTRY_VERIFY(m_dbWidget->isSearchActive()); + QTRY_COMPARE(entryView->model()->rowCount(), 1); + searchTextEdit->clear(); QTRY_VERIFY(searchTextEdit->text().isEmpty()); QTRY_VERIFY(searchTextEdit->hasFocus()); @@ -1736,6 +1760,8 @@ void TestGui::addCannedEntries() // Add entry "test" and confirm added QTest::mouseClick(entryNewWidget, Qt::LeftButton); QTest::keyClicks(titleEdit, "test"); + auto* editEntryWidgetTagsEdit = editEntryWidget->findChild("tagsList"); + editEntryWidgetTagsEdit->tags(QStringList() << "testTag"); auto* editEntryWidgetButtonBox = editEntryWidget->findChild("buttonBox"); QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);