mirror of
https://github.com/keepassxreboot/keepassxc.git
synced 2025-08-04 20:44:14 -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
33 changed files with 1541 additions and 73 deletions
|
@ -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;
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue