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
+
+
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 @@
1
+
+ TagsEdit
+ QAbstractScrollArea
+
+ 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);