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.
This commit is contained in:
Xavier Valls 2022-01-23 10:00:48 -05:00 committed by Jonathan White
parent 56a1b465a1
commit 4a21cee98c
33 changed files with 1541 additions and 73 deletions

View File

@ -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

View File

@ -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.\\*)

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M22 13C22 13.53 21.79 14.04 21.41 14.41L21 14.83C20.91 11.97 18.84 9.62 16.11 9.11L11 4H4V11L9.11 16.11C9.62 18.84 11.97 20.91 14.83 21L14.41 21.41C14.04 21.79 13.53 22 13 22C12.47 22 11.97 21.79 11.59 21.42L2.59 12.42C2.21 12.04 2 11.53 2 11V4C2 2.9 2.9 2 4 2H11C11.53 2 12.04 2.21 12.41 2.58L21.41 11.58C21.79 11.96 22 12.47 22 13M5 6.5C5 7.33 5.67 8 6.5 8S8 7.33 8 6.5 7.33 5 6.5 5 5 5.67 5 6.5M15.11 10.61C12.61 10.61 10.61 12.61 10.61 15.11S12.61 19.61 15.11 19.61C16 19.61 16.8 19.36 17.5 18.93L20.61 22L22 20.61L18.92 17.5C19.36 16.82 19.61 16 19.61 15.11C19.61 12.61 17.61 10.61 15.11 10.61M15.11 12.61C16.5 12.61 17.61 13.73 17.61 15.11S16.5 17.61 15.11 17.61 12.61 16.5 12.61 15.11 13.73 12.61 15.11 12.61" /></svg>

After

Width:  |  Height:  |  Size: 1010 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M21.41 11.58L12.41 2.58A2 2 0 0 0 11 2H4A2 2 0 0 0 2 4V11A2 2 0 0 0 2.59 12.42L11.59 21.42A2 2 0 0 0 13 22A2 2 0 0 0 14.41 21.41L21.41 14.41A2 2 0 0 0 22 13A2 2 0 0 0 21.41 11.58M13 20L4 11V4H11L20 13M6.5 5A1.5 1.5 0 1 1 5 6.5A1.5 1.5 0 0 1 6.5 5Z" /></svg>

After

Width:  |  Height:  |  Size: 542 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M17.63,5.84C17.27,5.33 16.67,5 16,5H5A2,2 0 0,0 3,7V17A2,2 0 0,0 5,19H16C16.67,19 17.27,18.66 17.63,18.15L22,12L17.63,5.84Z" /></svg>

After

Width:  |  Height:  |  Size: 418 B

View File

@ -72,6 +72,8 @@
<file>application/scalable/actions/system-help.svg</file>
<file>application/scalable/actions/system-search.svg</file>
<file>application/scalable/actions/system-software-update.svg</file>
<file>application/scalable/actions/tag.svg</file>
<file>application/scalable/actions/tag-search.svg</file>
<file>application/scalable/actions/trash.svg</file>
<file>application/scalable/actions/url-copy.svg</file>
<file>application/scalable/actions/user-guide.svg</file>

View File

@ -2398,6 +2398,10 @@ Disable safe saves and try again?</translation>
<source>Perform Auto-Type into the previously active window?</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Database Tags</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>EditEntryWidget</name>
@ -2886,6 +2890,14 @@ Would you like to correct it?</source>
<source>Edit Entry</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Tags:</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Tags list</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>EditEntryWidgetSSHAgent</name>
@ -3869,6 +3881,14 @@ Would you like to overwrite the existing attachment?</source>
<source>Default Sequence</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Tags</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Tags list</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>EntryURLModel</name>
@ -8501,6 +8521,21 @@ Please consider generating a new key file.</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>TagModel</name>
<message>
<source>All</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Expired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Weak Passwords</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>TotpDialog</name>
<message>

View File

@ -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

View File

@ -117,6 +117,7 @@ static const QHash<Config::ConfigKey, ConfigDirective> 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)}},

View File

@ -98,6 +98,7 @@ public:
GUI_SearchViewState,
GUI_PreviewSplitterState,
GUI_SplitterState,
GUI_GroupSplitterState,
GUI_AutoTypeSelectDialogSize,
GUI_CheckForUpdatesNextCheck,

View File

@ -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<QString> 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<QString> 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;

View File

@ -125,7 +125,8 @@ public:
bool containsDeletedObject(const DeletedObject& uuid) const;
void setDeletedObjects(const QList<DeletedObject>& delObjs);
QList<QString> commonUsernames();
const QStringList& commonUsernames() const;
const QStringList& tagList() const;
QSharedPointer<const CompositeKey> key() const;
bool setKey(const QSharedPointer<const CompositeKey>& 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<QString> m_commonUsernames;
QStringList m_commonUsernames;
QStringList m_tagList;
QUuid m_uuid;
static QHash<QUuid, QPointer<Database>> s_uuidMap;

View File

@ -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<PasswordHealth>& Entry::passwordHealth()
const QSharedPointer<PasswordHealth> Entry::passwordHealth()
{
if (!m_data.passwordHealth) {
m_data.passwordHealth.reset(new PasswordHealth(resolvePlaceholder(password())));
@ -218,6 +224,14 @@ const QSharedPointer<PasswordHealth>& Entry::passwordHealth()
return m_data.passwordHealth;
}
const QSharedPointer<PasswordHealth> Entry::passwordHealth() const
{
if (!m_data.passwordHealth) {
return QSharedPointer<PasswordHealth>::create(resolvePlaceholder(password()));
}
return m_data.passwordHealth;
}
bool Entry::excludeFromReports() const
{
return m_data.excludeFromReports

View File

@ -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>& passwordHealth();
const QSharedPointer<PasswordHealth> passwordHealth();
const QSharedPointer<PasswordHealth> passwordHealth() const;
bool excludeFromReports() const;
void setExcludeFromReports(bool state);

View File

@ -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);

View File

@ -38,7 +38,9 @@ public:
AttributeKV,
Attachment,
AttributeValue,
Group
Group,
Tag,
Is
};
struct SearchTerm

View File

@ -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<Database> 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<Database> 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<Database> 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<Database> 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<int> DatabaseWidget::mainSplitterSizes() const
QHash<Config::ConfigKey, QList<int>> 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<int>& sizes)
void DatabaseWidget::setSplitterSizes(const QHash<Config::ConfigKey, QList<int>>& sizes)
{
m_mainSplitter->setSizes(sizes);
}
QList<int> DatabaseWidget::previewSplitterSizes() const
{
return m_previewSplitter->sizes();
}
void DatabaseWidget::setPreviewSplitterSizes(const QList<int>& 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<Database> 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<TagModel*>(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();

View File

@ -20,6 +20,7 @@
#define KEEPASSX_DATABASEWIDGET_H
#include <QFileSystemWatcher>
#include <QListView>
#include <QStackedWidget>
#include "DatabaseOpenDialog.h"
@ -117,10 +118,8 @@ public:
QByteArray entryViewState() const;
bool setEntryViewState(const QByteArray& state) const;
QList<int> mainSplitterSizes() const;
void setMainSplitterSizes(const QList<int>& sizes);
QList<int> previewSplitterSizes() const;
void setPreviewSplitterSizes(const QList<int>& sizes);
QHash<Config::ConfigKey, QList<int>> splitterSizes() const;
void setSplitterSizes(const QHash<Config::ConfigKey, QList<int>>& 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<QWidget> m_mainWidget;
QPointer<QSplitter> m_mainSplitter;
QPointer<QSplitter> m_groupSplitter;
QPointer<MessageWidget> m_messageWidget;
QPointer<EntryPreviewWidget> m_previewView;
QPointer<QSplitter> m_previewSplitter;
@ -282,6 +283,7 @@ private:
QPointer<KeePass1OpenWidget> m_keepass1OpenWidget;
QPointer<OpVaultOpenWidget> m_opVaultOpenWidget;
QPointer<GroupView> m_groupView;
QPointer<QListView> m_tagView;
QPointer<EntryView> m_entryView;
QScopedPointer<Group> m_newGroup;

View File

@ -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();
}
/**

View File

@ -48,8 +48,7 @@ private:
QPointer<DatabaseWidget> m_activeDbWidget;
bool m_blockUpdates;
QList<int> m_mainSplitterSizes;
QList<int> m_previewSplitterSizes;
QHash<Config::ConfigKey, QList<int>> m_splitterSizes;
QByteArray m_listViewState;
QByteArray m_searchViewState;

View File

@ -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()

View File

@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>566</width>
<height>247</height>
<width>596</width>
<height>261</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout_7">
@ -171,7 +171,7 @@
<item>
<widget class="QTabWidget" name="entryTabWidget">
<property name="currentIndex">
<number>1</number>
<number>0</number>
</property>
<property name="documentMode">
<bool>false</bool>
@ -386,6 +386,35 @@
</property>
</widget>
</item>
<item row="2" column="5" alignment="Qt::AlignTop">
<widget class="QLabel" name="entryTagsTitleLabel">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Tags</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="2" column="6" alignment="Qt::AlignTop">
<widget class="TagsEdit" name="entryTagsList" native="true">
<property name="accessibleName">
<string>Tags list</string>
</property>
</widget>
</item>
<item row="2" column="0">
<spacer name="entryLeftHorizontalSpacer_5">
<property name="orientation">
@ -418,7 +447,7 @@
</property>
</spacer>
</item>
<item row="2" column="2" colspan="5">
<item row="2" column="2">
<layout class="QHBoxLayout" name="horizontalLayout_3" stretch="0,0">
<property name="spacing">
<number>6</number>
@ -1183,6 +1212,11 @@
<extends>QLabel</extends>
<header>gui/widgets/ElidedLabel.h</header>
</customwidget>
<customwidget>
<class>TagsEdit</class>
<extends>QWidget</extends>
<header>gui/tag/TagsEdit.h</header>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>entryTotpButton</tabstop>

View File

@ -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;

View File

@ -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()));

View File

@ -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());

View File

@ -56,7 +56,7 @@
<property name="verticalSpacing">
<number>8</number>
</property>
<item row="6" column="1">
<item row="7" column="1">
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QPlainTextEdit" name="notesEdit">
@ -99,7 +99,7 @@
</property>
</widget>
</item>
<item row="6" column="0">
<item row="7" column="0">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QCheckBox" name="notesEnabled">
@ -129,7 +129,7 @@
</item>
</layout>
</item>
<item row="5" column="1">
<item row="6" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="spacing">
<number>8</number>
@ -252,7 +252,7 @@
</property>
</widget>
</item>
<item row="5" column="0">
<item row="6" column="0">
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">
<number>0</number>
@ -272,6 +272,26 @@
</item>
</layout>
</item>
<item row="5" column="0">
<widget class="QLabel" name="tagsLabel">
<property name="text">
<string>Tags:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="TagsEdit" name="tagsList" native="true">
<property name="focusPolicy">
<enum>Qt::StrongFocus</enum>
</property>
<property name="accessibleName">
<string>Tags list</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
@ -288,12 +308,19 @@
<header>gui/URLEdit.h</header>
<container>1</container>
</customwidget>
<customwidget>
<class>TagsEdit</class>
<extends>QAbstractScrollArea</extends>
<header>gui/tag/TagsEdit.h</header>
<container>1</container>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>titleEdit</tabstop>
<tabstop>usernameComboBox</tabstop>
<tabstop>passwordEdit</tabstop>
<tabstop>urlEdit</tabstop>
<tabstop>tagsList</tabstop>
<tabstop>fetchFaviconButton</tabstop>
<tabstop>expireCheck</tabstop>
<tabstop>expireDatePicker</tabstop>

View File

@ -73,3 +73,7 @@ QPlainTextEdit, QTextEdit {
QStatusBar {
background-color: palette(window);
}
*[title="true"] {
font-weight: bold;
}

View File

@ -17,3 +17,7 @@ DatabaseWidget #SearchBanner, DatabaseWidget #KeeShareBanner {
QLineEdit {
padding-left: 2px;
}
*[title="true"] {
font-weight: bold;
}

89
src/gui/tag/TagModel.cpp Normal file
View File

@ -0,0 +1,89 @@
/*
* Copyright (C) 2018 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 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 <http://www.gnu.org/licenses/>.
*/
#include "TagModel.h"
#include "core/Database.h"
#include "gui/Icons.h"
TagModel::TagModel(QSharedPointer<Database> db, QObject* parent)
: QAbstractListModel(parent)
{
setDatabase(db);
}
TagModel::~TagModel()
{
}
void TagModel::setDatabase(QSharedPointer<Database> 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;
}

48
src/gui/tag/TagModel.h Normal file
View File

@ -0,0 +1,48 @@
/*
* Copyright (C) 2018 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 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 <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSX_TAGMODEL_H
#define KEEPASSX_TAGMODEL_H
#include <QAbstractListModel>
#include <QSharedPointer>
class Database;
class TagModel : public QAbstractListModel
{
Q_OBJECT
public:
explicit TagModel(QSharedPointer<Database> db, QObject* parent = nullptr);
~TagModel() override;
void setDatabase(QSharedPointer<Database> 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<Database> m_db;
QStringList m_tagList;
};
#endif // KEEPASSX_TAGMODEL_H

963
src/gui/tag/TagsEdit.cpp Normal file
View File

@ -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 <QApplication>
#include <QCompleter>
#include <QDebug>
#include <QPainter>
#include <QPainterPath>
#include <QScrollBar>
#include <QStyle>
#include <QStyleHints>
#include <QStyleOptionFrame>
#include <QTextLayout>
#include <cassert>
#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 <class It> 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<It>::difference_type;
using value_type = typename std::iterator_traits<It>::value_type;
using pointer = typename std::iterator_traits<It>::pointer;
using reference = typename std::iterator_traits<It>::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 <class It> EmptySkipIterator(It, It) -> EmptySkipIterator<It>;
} // 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<QCompleter>())
{
}
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 <class It> void drawTags(QPainter& p, std::pair<It, It> 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<Tag>& tags) const
{
return calcRects(tags, contentsRect());
}
QRect calcRects(QList<Tag>& 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<std::ptrdiff_t>(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 <class It>
void calcRects(QPoint& lt, size_t& row, QRect r, QFontMetrics const& fm, std::pair<It, It> 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 <class It> 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<std::ptrdiff_t>(i)), Tag());
if (editing_index >= i) {
++editing_index;
}
setEditingIndex(i);
moveCursor(0, false);
}
void setupCompleter()
{
completer->setWidget(ifce);
connect(completer.get(), qOverload<QString const&>(&QCompleter::activated), [this](QString const& text) {
currentText(text);
});
}
QVector<QTextLayout::FormatRange> 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<Tag> 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<QCompleter> completer;
int hscroll{0};
};
TagsEdit::TagsEdit(QWidget* parent)
: QAbstractScrollArea(parent)
, impl(std::make_unique<Impl>(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<size_t>(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<QCompleter>([&] {
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<Tag> 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;
}

78
src/gui/tag/TagsEdit.h Normal file
View File

@ -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 <QAbstractScrollArea>
#include <memory>
#include <vector>
/// 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> impl;
bool m_readOnly;
};

View File

@ -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<TagsEdit*>("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<TagsEdit*>("tagsList");
editEntryWidgetTagsEdit->tags(QStringList() << "testTag");
auto* editEntryWidgetButtonBox = editEntryWidget->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);