mirror of
https://github.com/keepassxreboot/keepassxc.git
synced 2024-10-01 01:26:01 -04:00
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:
parent
56a1b465a1
commit
4a21cee98c
2
COPYING
2
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
|
||||
|
@ -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.\\*)
|
||||
|
1
share/icons/application/scalable/actions/tag-search.svg
Normal file
1
share/icons/application/scalable/actions/tag-search.svg
Normal 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 |
1
share/icons/application/scalable/actions/tag.svg
Normal file
1
share/icons/application/scalable/actions/tag.svg
Normal 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 |
1
share/icons/application/scalable/categories/label.svg
Normal file
1
share/icons/application/scalable/categories/label.svg
Normal 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 |
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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)}},
|
||||
|
||||
|
@ -98,6 +98,7 @@ public:
|
||||
GUI_SearchViewState,
|
||||
GUI_PreviewSplitterState,
|
||||
GUI_SplitterState,
|
||||
GUI_GroupSplitterState,
|
||||
GUI_AutoTypeSelectDialogSize,
|
||||
GUI_CheckForUpdatesNextCheck,
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
|
@ -38,7 +38,9 @@ public:
|
||||
AttributeKV,
|
||||
Attachment,
|
||||
AttributeValue,
|
||||
Group
|
||||
Group,
|
||||
Tag,
|
||||
Is
|
||||
};
|
||||
|
||||
struct SearchTerm
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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;
|
||||
|
@ -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()
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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()));
|
||||
|
@ -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());
|
||||
|
||||
|
@ -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>
|
||||
|
@ -73,3 +73,7 @@ QPlainTextEdit, QTextEdit {
|
||||
QStatusBar {
|
||||
background-color: palette(window);
|
||||
}
|
||||
|
||||
*[title="true"] {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
@ -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
89
src/gui/tag/TagModel.cpp
Normal 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
48
src/gui/tag/TagModel.h
Normal 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
963
src/gui/tag/TagsEdit.cpp
Normal 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
78
src/gui/tag/TagsEdit.h
Normal 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;
|
||||
};
|
@ -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);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user