diff --git a/src/core/Config.cpp b/src/core/Config.cpp index 96fd385ff..fafb3cfd4 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -138,6 +138,8 @@ void Config::init(const QString& fileName) m_defaults.insert("GUI/DarkTrayIcon", false); m_defaults.insert("GUI/MinimizeToTray", false); m_defaults.insert("GUI/MinimizeOnClose", false); + m_defaults.insert("GUI/HideUsernames", false); + m_defaults.insert("GUI/HidePasswords", true); } Config* Config::instance() diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp index 8f4fc9bb3..361ca36fe 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -181,7 +181,7 @@ DatabaseWidget::DatabaseWidget(Database* db, QWidget* parent) connect(m_mainSplitter, SIGNAL(splitterMoved(int,int)), SIGNAL(mainSplitterSizesChanged())); connect(m_detailSplitter, SIGNAL(splitterMoved(int,int)), SIGNAL(detailSplitterSizesChanged())); - connect(m_entryView->header(), SIGNAL(sectionResized(int,int,int)), SIGNAL(entryColumnSizesChanged())); + connect(m_entryView, SIGNAL(viewStateChanged()), SIGNAL(entryViewStateChanged())); connect(m_groupView, SIGNAL(groupChanged(Group*)), this, SLOT(onGroupChanged(Group*))); connect(m_groupView, SIGNAL(groupChanged(Group*)), SIGNAL(groupChanged())); connect(m_entryView, SIGNAL(entryActivated(Entry*, EntryModel::ModelColumn)), @@ -290,28 +290,52 @@ void DatabaseWidget::setDetailSplitterSizes(const QList &sizes) m_detailSplitter->setSizes(sizes); } -QList DatabaseWidget::entryHeaderViewSizes() const +/** + * Get current state of entry view 'Hide Usernames' setting + */ +bool DatabaseWidget::isUsernamesHidden() const { - QList sizes; - - for (int i = 0; i < m_entryView->header()->count(); i++) { - sizes.append(m_entryView->header()->sectionSize(i)); - } - - return sizes; + return m_entryView->isUsernamesHidden(); } -void DatabaseWidget::setEntryViewHeaderSizes(const QList& sizes) +/** + * Set state of entry view 'Hide Usernames' setting + */ +void DatabaseWidget::setUsernamesHidden(const bool hide) { - const bool enoughSizes = sizes.size() == m_entryView->header()->count(); - Q_ASSERT(enoughSizes); - if (!enoughSizes) { - return; - } + m_entryView->setUsernamesHidden(hide); +} - for (int i = 0; i < sizes.size(); i++) { - m_entryView->header()->resizeSection(i, sizes[i]); - } +/** + * Get current state of entry view 'Hide Passwords' setting + */ +bool DatabaseWidget::isPasswordsHidden() const +{ + return m_entryView->isPasswordsHidden(); +} + +/** + * Set state of entry view 'Hide Passwords' setting + */ +void DatabaseWidget::setPasswordsHidden(const bool hide) +{ + m_entryView->setPasswordsHidden(hide); +} + +/** + * Get current view state of entry view + */ +QByteArray DatabaseWidget::entryViewState() const +{ + return m_entryView->viewState(); +} + +/** + * Set view state of entry view + */ +bool DatabaseWidget::setEntryViewState(const QByteArray& state) const +{ + return m_entryView->setViewState(state); } void DatabaseWidget::clearAllWidgets() @@ -660,8 +684,8 @@ void DatabaseWidget::openUrlForEntry(Entry* entry) void DatabaseWidget::createGroup() { + Q_ASSERT(m_groupView->currentGroup()); if (!m_groupView->currentGroup()) { - Q_ASSERT(false); return; } @@ -674,8 +698,8 @@ void DatabaseWidget::createGroup() void DatabaseWidget::deleteGroup() { Group* currentGroup = m_groupView->currentGroup(); + Q_ASSERT(currentGroup && canDeleteCurrentGroup()); if (!currentGroup || !canDeleteCurrentGroup()) { - Q_ASSERT(false); return; } @@ -910,10 +934,31 @@ void DatabaseWidget::unlockDatabase(bool accepted) void DatabaseWidget::entryActivationSignalReceived(Entry* entry, EntryModel::ModelColumn column) { - if (column == EntryModel::Url && !entry->url().isEmpty()) { - openUrlForEntry(entry); + Q_ASSERT(entry); + if (!entry) { + return; } - else { + + // Implement 'copy-on-doubleclick' functionality for certain columns + switch (column) { + case EntryModel::Username: + setClipboardTextAndMinimize(entry->resolveMultiplePlaceholders(entry->username())); + break; + case EntryModel::Password: + setClipboardTextAndMinimize(entry->resolveMultiplePlaceholders(entry->password())); + break; + case EntryModel::Url: + if (!entry->url().isEmpty()) { + openUrlForEntry(entry); + } + break; + // TODO: switch to 'Notes' tab in details view/pane + //case EntryModel::Notes: + // break; + // TODO: switch to 'Attachments' tab in details view/pane + //case EntryModel::Attachments: + // break; + default: switchToEntryEdit(entry); } } @@ -1151,7 +1196,7 @@ bool DatabaseWidget::canDeleteCurrentGroup() const bool DatabaseWidget::isInSearchMode() const { - return m_entryView->inEntryListMode(); + return m_entryView->inSearchMode(); } Group* DatabaseWidget::currentGroup() const diff --git a/src/gui/DatabaseWidget.h b/src/gui/DatabaseWidget.h index 3643bb03a..ba799893a 100644 --- a/src/gui/DatabaseWidget.h +++ b/src/gui/DatabaseWidget.h @@ -92,8 +92,12 @@ public: void setMainSplitterSizes(const QList& sizes); QList detailSplitterSizes() const; void setDetailSplitterSizes(const QList& sizes); - QList entryHeaderViewSizes() const; - void setEntryViewHeaderSizes(const QList& sizes); + bool isUsernamesHidden() const; + void setUsernamesHidden(const bool hide); + bool isPasswordsHidden() const; + void setPasswordsHidden(const bool hide); + QByteArray entryViewState() const; + bool setEntryViewState(const QByteArray& state) const; void clearAllWidgets(); bool currentEntryHasTitle(); bool currentEntryHasUsername(); @@ -127,7 +131,7 @@ signals: void searchModeActivated(); void mainSplitterSizesChanged(); void detailSplitterSizesChanged(); - void entryColumnSizesChanged(); + void entryViewStateChanged(); void updateSearch(QString text); public slots: diff --git a/src/gui/DatabaseWidgetStateSync.cpp b/src/gui/DatabaseWidgetStateSync.cpp index 57a3dcf86..39904ed99 100644 --- a/src/gui/DatabaseWidgetStateSync.cpp +++ b/src/gui/DatabaseWidgetStateSync.cpp @@ -27,16 +27,20 @@ DatabaseWidgetStateSync::DatabaseWidgetStateSync(QObject* parent) { m_mainSplitterSizes = variantToIntList(config()->get("GUI/SplitterState")); m_detailSplitterSizes = variantToIntList(config()->get("GUI/DetailSplitterState")); - m_columnSizesList = variantToIntList(config()->get("GUI/EntryListColumnSizes")); - m_columnSizesSearch = variantToIntList(config()->get("GUI/EntrySearchColumnSizes")); + m_hideUsernames = config()->get("GUI/HideUsernames").toBool(); + m_hidePasswords = config()->get("GUI/HidePasswords").toBool(); + m_listViewState = config()->get("GUI/ListViewState").toByteArray(); + m_searchViewState = config()->get("GUI/SearchViewState").toByteArray(); } DatabaseWidgetStateSync::~DatabaseWidgetStateSync() { config()->set("GUI/SplitterState", intListToVariant(m_mainSplitterSizes)); config()->set("GUI/DetailSplitterState", intListToVariant(m_detailSplitterSizes)); - config()->set("GUI/EntryListColumnSizes", intListToVariant(m_columnSizesList)); - config()->set("GUI/EntrySearchColumnSizes", intListToVariant(m_columnSizesSearch)); + config()->set("GUI/HideUsernames", m_hideUsernames); + config()->set("GUI/HidePasswords", m_hidePasswords); + config()->set("GUI/ListViewState", m_listViewState); + config()->set("GUI/SearchViewState", m_searchViewState); } void DatabaseWidgetStateSync::setActive(DatabaseWidget* dbWidget) @@ -70,8 +74,8 @@ void DatabaseWidgetStateSync::setActive(DatabaseWidget* dbWidget) SLOT(updateSplitterSizes())); connect(m_activeDbWidget, SIGNAL(detailSplitterSizesChanged()), SLOT(updateSplitterSizes())); - connect(m_activeDbWidget, SIGNAL(entryColumnSizesChanged()), - SLOT(updateColumnSizes())); + connect(m_activeDbWidget, SIGNAL(entryViewStateChanged()), + SLOT(updateViewState())); connect(m_activeDbWidget, SIGNAL(listModeActivated()), SLOT(restoreListView())); connect(m_activeDbWidget, SIGNAL(searchModeActivated()), @@ -83,19 +87,55 @@ void DatabaseWidgetStateSync::setActive(DatabaseWidget* dbWidget) } } +/** + * Restore entry view list view state + * + * NOTE: + * States of entry view 'Hide Usernames'/'Hide Passwords' settings are global, + * i.e. they are the same for both list and search mode + * + * NOTE: + * If m_listViewState is empty, the list view has been activated for the first + * time after starting with a clean (or invalid) config. Thus, save the current + * state. Without this, m_listViewState would remain empty until there is an + * actual view state change (e.g. column is resized) + */ void DatabaseWidgetStateSync::restoreListView() { - if (!m_columnSizesList.isEmpty()) { - m_activeDbWidget->setEntryViewHeaderSizes(m_columnSizesList); + m_activeDbWidget->setUsernamesHidden(m_hideUsernames); + m_activeDbWidget->setPasswordsHidden(m_hidePasswords); + + if (!m_listViewState.isEmpty()) { + m_activeDbWidget->setEntryViewState(m_listViewState); + } else { + m_listViewState = m_activeDbWidget->entryViewState(); } m_blockUpdates = false; } +/** + * Restore entry view search view state + * + * NOTE: + * States of entry view 'Hide Usernames'/'Hide Passwords' settings are global, + * i.e. they are the same for both list and search mode + * + * NOTE: + * If m_searchViewState is empty, the search view has been activated for the + * first time after starting with a clean (or invalid) config. Thus, save the + * current state. Without this, m_searchViewState would remain empty until + * there is an actual view state change (e.g. column is resized) + */ void DatabaseWidgetStateSync::restoreSearchView() { - if (!m_columnSizesSearch.isEmpty()) { - m_activeDbWidget->setEntryViewHeaderSizes(m_columnSizesSearch); + m_activeDbWidget->setUsernamesHidden(m_hideUsernames); + m_activeDbWidget->setPasswordsHidden(m_hidePasswords); + + if (!m_searchViewState.isEmpty()) { + m_activeDbWidget->setEntryViewState(m_searchViewState); + } else { + m_searchViewState = m_activeDbWidget->entryViewState(); } m_blockUpdates = false; @@ -116,17 +156,26 @@ void DatabaseWidgetStateSync::updateSplitterSizes() m_detailSplitterSizes = m_activeDbWidget->detailSplitterSizes(); } -void DatabaseWidgetStateSync::updateColumnSizes() +/** + * Update entry view list/search view state + * + * NOTE: + * States of entry view 'Hide Usernames'/'Hide Passwords' settings are global, + * i.e. they are the same for both list and search mode + */ +void DatabaseWidgetStateSync::updateViewState() { if (m_blockUpdates) { return; } - if (m_activeDbWidget->isGroupSelected()) { - m_columnSizesList = m_activeDbWidget->entryHeaderViewSizes(); - } - else { - m_columnSizesSearch = m_activeDbWidget->entryHeaderViewSizes(); + m_hideUsernames = m_activeDbWidget->isUsernamesHidden(); + m_hidePasswords = m_activeDbWidget->isPasswordsHidden(); + + if (m_activeDbWidget->isInSearchMode()) { + m_searchViewState = m_activeDbWidget->entryViewState(); + } else { + m_listViewState = m_activeDbWidget->entryViewState(); } } @@ -140,8 +189,7 @@ QList DatabaseWidgetStateSync::variantToIntList(const QVariant& variant) int size = var.toInt(&ok); if (ok) { result.append(size); - } - else { + } else { result.clear(); break; } @@ -160,4 +208,3 @@ QVariant DatabaseWidgetStateSync::intListToVariant(const QList& list) return result; } - diff --git a/src/gui/DatabaseWidgetStateSync.h b/src/gui/DatabaseWidgetStateSync.h index 79a8ded38..aca593f1f 100644 --- a/src/gui/DatabaseWidgetStateSync.h +++ b/src/gui/DatabaseWidgetStateSync.h @@ -37,7 +37,7 @@ public slots: private slots: void blockUpdates(); void updateSplitterSizes(); - void updateColumnSizes(); + void updateViewState(); private: static QList variantToIntList(const QVariant& variant); @@ -48,8 +48,12 @@ private: bool m_blockUpdates; QList m_mainSplitterSizes; QList m_detailSplitterSizes; - QList m_columnSizesList; - QList m_columnSizesSearch; + + bool m_hideUsernames; + bool m_hidePasswords; + + QByteArray m_listViewState; + QByteArray m_searchViewState; }; #endif // KEEPASSX_DATABASEWIDGETSTATESYNC_H diff --git a/src/gui/DetailsWidget.cpp b/src/gui/DetailsWidget.cpp index 1d20984be..44c29f8f8 100644 --- a/src/gui/DetailsWidget.cpp +++ b/src/gui/DetailsWidget.cpp @@ -127,7 +127,7 @@ void DetailsWidget::getSelectedEntry(Entry* selectedEntry) shortPassword(m_currentEntry->resolveMultiplePlaceholders(m_currentEntry->password()))); m_ui->passwordLabel->setToolTip(m_currentEntry->resolveMultiplePlaceholders(m_currentEntry->password())); } else { - m_ui->passwordLabel->setText("****"); + m_ui->passwordLabel->setText(QString("\u25cf").repeated(6)); } QString url = m_currentEntry->webUrl(); diff --git a/src/gui/entry/EntryModel.cpp b/src/gui/entry/EntryModel.cpp index 4d3578069..4a583b30e 100644 --- a/src/gui/entry/EntryModel.cpp +++ b/src/gui/entry/EntryModel.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include "core/DatabaseIcons.h" #include "core/Entry.h" @@ -27,9 +28,20 @@ #include "core/Group.h" #include "core/Metadata.h" +// String being displayed when hiding content +const QString EntryModel::HiddenContentDisplay(QString("\u25cf").repeated(6)); + +// Format used to display dates +const Qt::DateFormat EntryModel::DateFormat = Qt::DefaultLocaleShortDate; + +// Paperclip symbol +const QString EntryModel::PaperClipDisplay("\U0001f4ce"); + EntryModel::EntryModel(QObject* parent) : QAbstractTableModel(parent) , m_group(nullptr) + , m_hideUsernames(false) + , m_hidePasswords(true) { } @@ -64,7 +76,7 @@ void EntryModel::setGroup(Group* group) makeConnections(group); endResetModel(); - emit switchedToGroupMode(); + emit switchedToListMode(); } void EntryModel::setEntryList(const QList& entries) @@ -101,24 +113,26 @@ void EntryModel::setEntryList(const QList& entries) } endResetModel(); - emit switchedToEntryListMode(); + emit switchedToSearchMode(); } int EntryModel::rowCount(const QModelIndex& parent) const { if (parent.isValid()) { return 0; - } - else { + } else { return m_entries.size(); } } int EntryModel::columnCount(const QModelIndex& parent) const { - Q_UNUSED(parent); + // Advised by Qt documentation + if (parent.isValid()) { + return 0; + } - return 4; + return 12; } QVariant EntryModel::data(const QModelIndex& index, int role) const @@ -141,24 +155,95 @@ QVariant EntryModel::data(const QModelIndex& index, int role) const case Title: result = entry->resolveMultiplePlaceholders(entry->title()); if (attr->isReference(EntryAttributes::TitleKey)) { - result.prepend(tr("Ref: ","Reference abbreviation")); + result.prepend(tr("Ref: ", "Reference abbreviation")); } return result; case Username: - result = entry->resolveMultiplePlaceholders(entry->username()); + if (m_hideUsernames) { + result = EntryModel::HiddenContentDisplay; + } else { + result = entry->resolveMultiplePlaceholders(entry->username()); + } if (attr->isReference(EntryAttributes::UserNameKey)) { - result.prepend(tr("Ref: ","Reference abbreviation")); + result.prepend(tr("Ref: ", "Reference abbreviation")); + } + return result; + case Password: + if (m_hidePasswords) { + result = EntryModel::HiddenContentDisplay; + } else { + result = entry->resolveMultiplePlaceholders(entry->password()); + } + if (attr->isReference(EntryAttributes::PasswordKey)) { + result.prepend(tr("Ref: ", "Reference abbreviation")); } return result; case Url: - result = entry->displayUrl(); + result = entry->resolveMultiplePlaceholders(entry->displayUrl()); if (attr->isReference(EntryAttributes::URLKey)) { - result.prepend(tr("Ref: ","Reference abbreviation")); + result.prepend(tr("Ref: ", "Reference abbreviation")); + } + return result; + case Notes: + // Display only first line of notes in simplified format + result = entry->resolveMultiplePlaceholders(entry->notes().section("\n", 0, 0).simplified()); + if (attr->isReference(EntryAttributes::NotesKey)) { + result.prepend(tr("Ref: ", "Reference abbreviation")); + } + return result; + case Expires: + // Display either date of expiry or 'Never' + result = entry->timeInfo().expires() ? entry->timeInfo().expiryTime().toLocalTime().toString(EntryModel::DateFormat) : tr("Never"); + return result; + case Created: + result = entry->timeInfo().creationTime().toLocalTime().toString(EntryModel::DateFormat); + return result; + case Modified: + result = entry->timeInfo().lastModificationTime().toLocalTime().toString(EntryModel::DateFormat); + return result; + case Accessed: + result = entry->timeInfo().lastAccessTime().toLocalTime().toString(EntryModel::DateFormat); + return result; + case Paperclip: + result = entry->attachments()->keys().isEmpty() ? QString() : EntryModel::PaperClipDisplay; + return result; + case Attachments: + // Display comma-separated list of attachments + QList attachments = entry->attachments()->keys(); + for (int i = 0; i < attachments.size(); ++i) { + if (result.isEmpty()) { + result.append(attachments.at(i)); + continue; + } + result.append(QString(", ") + attachments.at(i)); } return result; } - } - else if (role == Qt::DecorationRole) { + } else if (role == Qt::UserRole) { // Qt::UserRole is used as sort role, see EntryView::EntryView() + switch (index.column()) { + case Username: + return entry->resolveMultiplePlaceholders(entry->username()); + case Password: + return entry->resolveMultiplePlaceholders(entry->password()); + case Expires: + // There seems to be no better way of expressing 'infinity' + return entry->timeInfo().expires() ? entry->timeInfo().expiryTime() : QDateTime(QDate(9999, 1, 1)); + case Created: + return entry->timeInfo().creationTime(); + case Modified: + return entry->timeInfo().lastModificationTime(); + case Accessed: + return entry->timeInfo().lastAccessTime(); + case Paperclip: + // Display entries with attachments above those without when + // sorting ascendingly (and vice versa when sorting descendingly) + return entry->attachments()->keys().isEmpty() ? 1 : 0; + default: + // For all other columns, simply use data provided by Qt::Display- + // Role for sorting + return data(index, Qt::DisplayRole); + } + } else if (role == Qt::DecorationRole) { switch (index.column()) { case ParentGroup: if (entry->group()) { @@ -168,20 +253,17 @@ QVariant EntryModel::data(const QModelIndex& index, int role) const case Title: if (entry->isExpired()) { return databaseIcons()->iconPixmap(DatabaseIcons::ExpiredIconIndex); - } - else { + } else { return entry->iconScaledPixmap(); } } - } - else if (role == Qt::FontRole) { + } else if (role == Qt::FontRole) { QFont font; if (entry->isExpired()) { font.setStrikeOut(true); } return font; - } - else if (role == Qt::TextColorRole) { + } else if (role == Qt::TextColorRole) { if (entry->hasReferences()) { QPalette p; return QVariant(p.color(QPalette::Active, QPalette::Mid)); @@ -190,6 +272,7 @@ QVariant EntryModel::data(const QModelIndex& index, int role) const return QVariant(); } + QVariant EntryModel::headerData(int section, Qt::Orientation orientation, int role) const { if (orientation == Qt::Horizontal && role == Qt::DisplayRole) { @@ -200,8 +283,24 @@ QVariant EntryModel::headerData(int section, Qt::Orientation orientation, int ro return tr("Title"); case Username: return tr("Username"); + case Password: + return tr("Password"); case Url: return tr("URL"); + case Notes: + return tr("Notes"); + case Expires: + return tr("Expires"); + case Created: + return tr("Created"); + case Modified: + return tr("Modified"); + case Accessed: + return tr("Accessed"); + case Paperclip: + return EntryModel::PaperClipDisplay; + case Attachments: + return tr("Attachments"); } } @@ -222,8 +321,7 @@ Qt::ItemFlags EntryModel::flags(const QModelIndex& modelIndex) const { if (!modelIndex.isValid()) { return Qt::NoItemFlags; - } - else { + } else { return QAbstractItemModel::flags(modelIndex) | Qt::ItemIsDragEnabled; } } @@ -264,8 +362,7 @@ QMimeData* EntryModel::mimeData(const QModelIndexList& indexes) const if (seenEntries.isEmpty()) { delete data; return nullptr; - } - else { + } else { data->setData(mimeTypes().at(0), encoded); return data; } @@ -337,3 +434,53 @@ void EntryModel::makeConnections(const Group* group) connect(group, SIGNAL(entryRemoved(Entry*)), SLOT(entryRemoved())); connect(group, SIGNAL(entryDataChanged(Entry*)), SLOT(entryDataChanged(Entry*))); } + +/** + * Get current state of 'Hide Usernames' setting + */ +bool EntryModel::isUsernamesHidden() const +{ + return m_hideUsernames; +} + +/** + * Set state of 'Hide Usernames' setting and signal change + */ +void EntryModel::setUsernamesHidden(const bool hide) +{ + m_hideUsernames = hide; + emit usernamesHiddenChanged(); +} + +/** + * Get current state of 'Hide Passwords' setting + */ +bool EntryModel::isPasswordsHidden() const +{ + return m_hidePasswords; +} + +/** + * Set state of 'Hide Passwords' setting and signal change + */ +void EntryModel::setPasswordsHidden(const bool hide) +{ + m_hidePasswords = hide; + emit passwordsHiddenChanged(); +} + +/** + * Toggle state of 'Hide Usernames' setting + */ +void EntryModel::toggleUsernamesHidden(const bool hide) +{ + setUsernamesHidden(hide); +} + +/** + * Toggle state of 'Hide Passwords' setting + */ +void EntryModel::togglePasswordsHidden(const bool hide) +{ + setPasswordsHidden(hide); +} diff --git a/src/gui/entry/EntryModel.h b/src/gui/entry/EntryModel.h index d12982d83..7c668a5f6 100644 --- a/src/gui/entry/EntryModel.h +++ b/src/gui/entry/EntryModel.h @@ -33,7 +33,15 @@ public: ParentGroup = 0, Title = 1, Username = 2, - Url = 3 + Password = 3, + Url = 4, + Notes = 5, + Expires = 6, + Created = 7, + Modified = 8, + Accessed = 9, + Paperclip = 10, + Attachments = 11 }; explicit EntryModel(QObject* parent = nullptr); @@ -52,12 +60,21 @@ public: void setEntryList(const QList& entries); + bool isUsernamesHidden() const; + void setUsernamesHidden(const bool hide); + bool isPasswordsHidden() const; + void setPasswordsHidden(const bool hide); + signals: - void switchedToEntryListMode(); - void switchedToGroupMode(); + void switchedToListMode(); + void switchedToSearchMode(); + void usernamesHiddenChanged(); + void passwordsHiddenChanged(); public slots: void setGroup(Group* group); + void toggleUsernamesHidden(const bool hide); + void togglePasswordsHidden(const bool hide); private slots: void entryAboutToAdd(Entry* entry); @@ -74,6 +91,13 @@ private: QList m_entries; QList m_orgEntries; QList m_allGroups; + + bool m_hideUsernames; + bool m_hidePasswords; + + static const QString HiddenContentDisplay; + static const Qt::DateFormat DateFormat; + static const QString PaperClipDisplay; }; #endif // KEEPASSX_ENTRYMODEL_H diff --git a/src/gui/entry/EntryView.cpp b/src/gui/entry/EntryView.cpp index ac8e66782..ceb3c64a0 100644 --- a/src/gui/entry/EntryView.cpp +++ b/src/gui/entry/EntryView.cpp @@ -19,6 +19,7 @@ #include #include +#include #include "gui/SortFilterHideProxyModel.h" @@ -26,12 +27,14 @@ EntryView::EntryView(QWidget* parent) : QTreeView(parent) , m_model(new EntryModel(this)) , m_sortModel(new SortFilterHideProxyModel(this)) - , m_inEntryListMode(false) + , m_inSearchMode(false) { m_sortModel->setSourceModel(m_model); m_sortModel->setDynamicSortFilter(true); m_sortModel->setSortLocaleAware(true); m_sortModel->setSortCaseSensitivity(Qt::CaseInsensitive); + // Use Qt::UserRole as sort role, see EntryModel::data() + m_sortModel->setSortRole(Qt::UserRole); QTreeView::setModel(m_sortModel); setUniformRowHeights(true); @@ -40,17 +43,72 @@ EntryView::EntryView(QWidget* parent) setDragEnabled(true); setSortingEnabled(true); setSelectionMode(QAbstractItemView::ExtendedSelection); - header()->setDefaultSectionSize(150); // QAbstractItemView::startDrag() uses this property as the default drag action setDefaultDropAction(Qt::MoveAction); connect(this, SIGNAL(doubleClicked(QModelIndex)), SLOT(emitEntryActivated(QModelIndex))); connect(selectionModel(), SIGNAL(selectionChanged(QItemSelection,QItemSelection)), SIGNAL(entrySelectionChanged())); - connect(m_model, SIGNAL(switchedToEntryListMode()), SLOT(switchToEntryListMode())); - connect(m_model, SIGNAL(switchedToGroupMode()), SLOT(switchToGroupMode())); - + connect(m_model, SIGNAL(switchedToListMode()), SLOT(switchToListMode())); + connect(m_model, SIGNAL(switchedToSearchMode()), SLOT(switchToSearchMode())); + connect(m_model, SIGNAL(usernamesHiddenChanged()), SIGNAL(viewStateChanged())); + connect(m_model, SIGNAL(passwordsHiddenChanged()), SIGNAL(viewStateChanged())); connect(this, SIGNAL(clicked(QModelIndex)), SLOT(emitEntryPressed(QModelIndex))); + + m_headerMenu = new QMenu(this); + m_headerMenu->setTitle(tr("Customize View")); + m_headerMenu->addSection(tr("Customize View")); + + m_hideUsernamesAction = m_headerMenu->addAction(tr("Hide Usernames"), m_model, SLOT(toggleUsernamesHidden(bool))); + m_hideUsernamesAction->setCheckable(true); + m_hidePasswordsAction = m_headerMenu->addAction(tr("Hide Passwords"), m_model, SLOT(togglePasswordsHidden(bool))); + m_hidePasswordsAction->setCheckable(true); + m_headerMenu->addSeparator(); + + // Actions to toggle column visibility, each carrying the corresponding + // colummn index as data + m_columnActions = new QActionGroup(this); + m_columnActions->setExclusive(false); + for (int columnIndex = 1; columnIndex < header()->count(); ++columnIndex) { + QString caption = m_model->headerData(columnIndex, Qt::Horizontal, Qt::DisplayRole).toString(); + QAction* action = m_headerMenu->addAction(caption); + action->setCheckable(true); + action->setData(columnIndex); + m_columnActions->addAction(action); + } + connect(m_columnActions, SIGNAL(triggered(QAction*)), this, SLOT(toggleColumnVisibility(QAction*))); + + m_headerMenu->addSeparator(); + m_headerMenu->addAction(tr("Fit to window"), this, SLOT(fitColumnsToWindow())); + m_headerMenu->addAction(tr("Fit to contents"), this, SLOT(fitColumnsToContents())); + m_headerMenu->addSeparator(); + m_headerMenu->addAction(tr("Reset to defaults"), this, SLOT(resetViewToDefaults())); + + header()->setDefaultSectionSize(100); + // Stretching of last section interferes with fitting columns to window + header()->setStretchLastSection(false); + header()->setContextMenuPolicy(Qt::CustomContextMenu); + connect(header(), SIGNAL(customContextMenuRequested(QPoint)), this, SLOT(showHeaderMenu(QPoint))); + connect(header(), SIGNAL(sectionCountChanged(int, int)), this, SIGNAL(viewStateChanged())); + connect(header(), SIGNAL(sectionMoved(int, int, int)), this, SIGNAL(viewStateChanged())); + connect(header(), SIGNAL(sectionResized(int, int, int)), this, SIGNAL(viewStateChanged())); + connect(header(), SIGNAL(sortIndicatorChanged(int, Qt::SortOrder)), this, SIGNAL(viewStateChanged())); + + // TODO: not working as expected, columns will end up being very small, + // most likely due to the widget not being sized properly at this time + //fitColumnsToWindow(); + + // Configure default search view state and save for later use + header()->showSection(EntryModel::ParentGroup); + m_sortModel->sort(EntryModel::ParentGroup, Qt::AscendingOrder); + sortByColumn(EntryModel::ParentGroup, Qt::AscendingOrder); + m_defaultSearchViewState = header()->saveState(); + + // Configure default list view state and save for later use + header()->hideSection(EntryModel::ParentGroup); + m_sortModel->sort(EntryModel::Title, Qt::AscendingOrder); + sortByColumn(EntryModel::Title, Qt::AscendingOrder); + m_defaultListViewState = header()->saveState(); } void EntryView::keyPressEvent(QKeyEvent* event) @@ -83,15 +141,14 @@ void EntryView::setFirstEntryActive() if (m_model->rowCount() > 0) { QModelIndex index = m_sortModel->mapToSource(m_sortModel->index(0, 0)); setCurrentEntry(m_model->entryFromIndex(index)); - } - else { + } else { emit entrySelectionChanged(); } } -bool EntryView::inEntryListMode() +bool EntryView::inSearchMode() { - return m_inEntryListMode; + return m_inSearchMode; } void EntryView::emitEntryActivated(const QModelIndex& index) @@ -117,8 +174,7 @@ Entry* EntryView::currentEntry() QModelIndexList list = selectionModel()->selectedRows(); if (list.size() == 1) { return m_model->entryFromIndex(m_sortModel->mapToSource(list.first())); - } - else { + } else { return nullptr; } } @@ -138,30 +194,222 @@ Entry* EntryView::entryFromIndex(const QModelIndex& index) { if (index.isValid()) { return m_model->entryFromIndex(m_sortModel->mapToSource(index)); - } - else { + } else { return nullptr; } } -void EntryView::switchToEntryListMode() +/** + * Switch to list mode, i.e. list entries of group + */ +void EntryView::switchToListMode() { - m_sortModel->hideColumn(0, false); + if (!m_inSearchMode) { + return; + } - m_sortModel->sort(1, Qt::AscendingOrder); - m_sortModel->sort(0, Qt::AscendingOrder); - sortByColumn(0, Qt::AscendingOrder); - - m_inEntryListMode = true; + header()->hideSection(EntryModel::ParentGroup); + m_inSearchMode = false; } -void EntryView::switchToGroupMode() +/** + * Switch to search mode, i.e. list search results + */ +void EntryView::switchToSearchMode() { - m_sortModel->hideColumn(0, true); + if (m_inSearchMode) { + return; + } - m_sortModel->sort(-1, Qt::AscendingOrder); - m_sortModel->sort(0, Qt::AscendingOrder); - sortByColumn(0, Qt::AscendingOrder); + header()->showSection(EntryModel::ParentGroup); - m_inEntryListMode = false; + // Always set sorting to column 'Group', as it does not feel right to + // have the last known sort configuration of search view restored by + // 'DatabaseWidgetStateSync', which is what happens without this + m_sortModel->sort(EntryModel::ParentGroup, Qt::AscendingOrder); + sortByColumn(EntryModel::ParentGroup, Qt::AscendingOrder); + + m_inSearchMode = true; +} + +/** + * Get current state of 'Hide Usernames' setting (NOTE: just pass-through for + * m_model) + */ +bool EntryView::isUsernamesHidden() const +{ + return m_model->isUsernamesHidden(); +} + +/** + * Set state of 'Hide Usernames' setting (NOTE: just pass-through for m_model) + */ +void EntryView::setUsernamesHidden(const bool hide) +{ + m_model->setUsernamesHidden(hide); +} + +/** + * Get current state of 'Hide Passwords' setting (NOTE: just pass-through for + * m_model) + */ +bool EntryView::isPasswordsHidden() const +{ + return m_model->isPasswordsHidden(); +} + +/** + * Set state of 'Hide Passwords' setting (NOTE: just pass-through for m_model) + */ +void EntryView::setPasswordsHidden(const bool hide) +{ + m_model->setPasswordsHidden(hide); +} + +/** + * Get current view state + */ +QByteArray EntryView::viewState() const +{ + return header()->saveState(); +} + +/** + * Set view state + */ +bool EntryView::setViewState(const QByteArray& state) const +{ + return header()->restoreState(state); +} + +/** + * Sync checkable menu actions to current state and display header context + * menu at specified position + */ +void EntryView::showHeaderMenu(const QPoint& position) +{ + m_hideUsernamesAction->setChecked(m_model->isUsernamesHidden()); + m_hidePasswordsAction->setChecked(m_model->isPasswordsHidden()); + const QList actions = m_columnActions->actions(); + for (auto& action : actions) { + Q_ASSERT(static_cast(action->data().type()) == QMetaType::Int); + if (static_cast(action->data().type()) != QMetaType::Int) { + continue; + } + int columnIndex = action->data().toInt(); + bool hidden = header()->isSectionHidden(columnIndex) || (header()->sectionSize(columnIndex) == 0); + action->setChecked(!hidden); + } + + m_headerMenu->popup(mapToGlobal(position)); +} + +/** + * Toggle visibility of column referenced by triggering action + */ +void EntryView::toggleColumnVisibility(QAction *action) +{ + // Verify action carries a column index as data. Since QVariant.toInt() + // below will accept anything that's interpretable as int, perform a type + // check here to make sure data actually IS int + Q_ASSERT(static_cast(action->data().type()) == QMetaType::Int); + if (static_cast(action->data().type()) != QMetaType::Int) { + return; + } + + // Toggle column visibility. Visible columns will only be hidden if at + // least one visible column remains, as the table header will disappear + // entirely when all columns are hidden + int columnIndex = action->data().toInt(); + if (action->isChecked()) { + header()->showSection(columnIndex); + if (header()->sectionSize(columnIndex) == 0) { + header()->resizeSection(columnIndex, header()->defaultSectionSize()); + } + return; + } + if ((header()->count() - header()->hiddenSectionCount()) > 1) { + header()->hideSection(columnIndex); + return; + } + action->setChecked(true); +} + +/** + * Resize columns to fit all visible columns within the available space + * + * NOTE: + * If EntryView::resizeEvent() is overridden at some point in the future, + * its implementation MUST call the corresponding parent method using + * 'QTreeView::resizeEvent(event)'. Without this, fitting to window will + * be broken and/or work unreliably (stumbled upon during testing) + * + * NOTE: + * Testing showed that it is absolutely necessary to emit signal 'viewState + * Changed' here. Without this, an incomplete view state might get saved by + * 'DatabaseWidgetStateSync' (e.g. only some columns resized) + */ +void EntryView::fitColumnsToWindow() +{ + header()->resizeSections(QHeaderView::Stretch); + emit viewStateChanged(); +} + +/** + * Resize columns to fit current table contents, i.e. make all contents + * entirely visible + */ +void EntryView::fitColumnsToContents() +{ + // Resize columns to fit contents + header()->resizeSections(QHeaderView::ResizeToContents); + + // Determine total width of currently visible columns. If there is + // still some space available on the header, equally distribute it to + // visible columns and add remaining fraction to last visible column + int width = 0; + for (int columnIndex = 0; columnIndex < header()->count(); ++columnIndex) { + if (!header()->isSectionHidden(columnIndex)) { + width += header()->sectionSize(columnIndex); + } + } + int visible = header()->count() - header()->hiddenSectionCount(); + int avail = header()->width() - width; + if ((visible <= 0) || (avail <= 0)) { + return; + } + int add = avail / visible; + width = 0; + int last = 0; + for (int columnIndex = 0; columnIndex < header()->count(); ++columnIndex) { + if (!header()->isSectionHidden(columnIndex)) { + header()->resizeSection(columnIndex, header()->sectionSize(columnIndex) + add); + width += header()->sectionSize(columnIndex); + if (header()->visualIndex(columnIndex) > last) { + last = header()->visualIndex(columnIndex); + } + } + } + header()->resizeSection(header()->logicalIndex(last), header()->sectionSize(last) + (header()->width() - width)); + + // Shouldn't be necessary due to use of header()->resizeSection, but + // lets just do it anyway for the sake of completeness + emit viewStateChanged(); +} + +/** + * Reset view to defaults + */ +void EntryView::resetViewToDefaults() +{ + m_model->setUsernamesHidden(false); + m_model->setPasswordsHidden(true); + + if (m_inSearchMode) { + header()->restoreState(m_defaultSearchViewState); + } else { + header()->restoreState(m_defaultListViewState); + } + + fitColumnsToWindow(); } diff --git a/src/gui/entry/EntryView.h b/src/gui/entry/EntryView.h index 14c6b7ccc..1bea22f49 100644 --- a/src/gui/entry/EntryView.h +++ b/src/gui/entry/EntryView.h @@ -26,6 +26,7 @@ class Entry; class EntryModel; class Group; class SortFilterHideProxyModel; +class QActionGroup; class EntryView : public QTreeView { @@ -38,9 +39,15 @@ public: void setCurrentEntry(Entry* entry); Entry* entryFromIndex(const QModelIndex& index); void setEntryList(const QList& entries); - bool inEntryListMode(); + bool inSearchMode(); int numberOfSelectedEntries(); void setFirstEntryActive(); + bool isUsernamesHidden() const; + void setUsernamesHidden(const bool hide); + bool isPasswordsHidden() const; + void setPasswordsHidden(const bool hide); + QByteArray viewState() const; + bool setViewState(const QByteArray& state) const; public slots: void setGroup(Group* group); @@ -49,6 +56,7 @@ signals: void entryActivated(Entry* entry, EntryModel::ModelColumn column); void entryPressed(Entry* entry); void entrySelectionChanged(); + void viewStateChanged(); protected: void keyPressEvent(QKeyEvent* event) override; @@ -56,13 +64,26 @@ protected: private slots: void emitEntryActivated(const QModelIndex& index); void emitEntryPressed(const QModelIndex& index); - void switchToEntryListMode(); - void switchToGroupMode(); + void switchToListMode(); + void switchToSearchMode(); + void showHeaderMenu(const QPoint& position); + void toggleColumnVisibility(QAction *action); + void fitColumnsToWindow(); + void fitColumnsToContents(); + void resetViewToDefaults(); private: EntryModel* const m_model; SortFilterHideProxyModel* const m_sortModel; - bool m_inEntryListMode; + bool m_inSearchMode; + + QByteArray m_defaultListViewState; + QByteArray m_defaultSearchViewState; + + QMenu* m_headerMenu; + QAction* m_hideUsernamesAction; + QAction* m_hidePasswordsAction; + QActionGroup* m_columnActions; }; #endif // KEEPASSX_ENTRYVIEW_H diff --git a/tests/TestEntryModel.cpp b/tests/TestEntryModel.cpp index ad7c9060a..ab4ecf754 100644 --- a/tests/TestEntryModel.cpp +++ b/tests/TestEntryModel.cpp @@ -288,9 +288,15 @@ void TestEntryModel::testProxyModel() modelSource->setGroup(db->rootGroup()); + /** + * @author Fonic + * Update comparison value of modelProxy->columnCount() to account for + * additional columns 'Password', 'Notes', 'Expires', 'Created', 'Modified', + * 'Accessed', 'Paperclip' and 'Attachments' + */ QSignalSpy spyColumnRemove(modelProxy, SIGNAL(columnsAboutToBeRemoved(QModelIndex,int,int))); modelProxy->hideColumn(0, true); - QCOMPARE(modelProxy->columnCount(), 3); + QCOMPARE(modelProxy->columnCount(), 11); QVERIFY(spyColumnRemove.size() >= 1); int oldSpyColumnRemoveSize = spyColumnRemove.size(); @@ -304,9 +310,15 @@ void TestEntryModel::testProxyModel() entryList << entry; modelSource->setEntryList(entryList); + /** + * @author Fonic + * Update comparison value of modelProxy->columnCount() to account for + * additional columns 'Password', 'Notes', 'Expires', 'Created', 'Modified', + * 'Accessed', 'Paperclip' and 'Attachments' + */ QSignalSpy spyColumnInsert(modelProxy, SIGNAL(columnsAboutToBeInserted(QModelIndex,int,int))); modelProxy->hideColumn(0, false); - QCOMPARE(modelProxy->columnCount(), 4); + QCOMPARE(modelProxy->columnCount(), 12); QVERIFY(spyColumnInsert.size() >= 1); int oldSpyColumnInsertSize = spyColumnInsert.size(); diff --git a/tests/gui/TestGui.cpp b/tests/gui/TestGui.cpp index d1a251069..a663b30a3 100644 --- a/tests/gui/TestGui.cpp +++ b/tests/gui/TestGui.cpp @@ -698,7 +698,7 @@ void TestGui::testDeleteEntry() QWidget* entryDeleteWidget = toolBar->widgetForAction(entryDeleteAction); QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::ViewMode); - clickIndex(entryView->model()->index(1, 0), entryView, Qt::LeftButton); + clickIndex(entryView->model()->index(1, 1), entryView, Qt::LeftButton); QVERIFY(entryDeleteWidget->isVisible()); QVERIFY(entryDeleteWidget->isEnabled()); QVERIFY(!m_db->metadata()->recycleBin()); @@ -709,8 +709,8 @@ void TestGui::testDeleteEntry() QCOMPARE(entryView->model()->rowCount(), 3); QCOMPARE(m_db->metadata()->recycleBin()->entries().size(), 1); - clickIndex(entryView->model()->index(1, 0), entryView, Qt::LeftButton); - clickIndex(entryView->model()->index(2, 0), entryView, Qt::LeftButton, Qt::ControlModifier); + clickIndex(entryView->model()->index(1, 1), entryView, Qt::LeftButton); + clickIndex(entryView->model()->index(2, 1), entryView, Qt::LeftButton, Qt::ControlModifier); QCOMPARE(entryView->selectionModel()->selectedRows().size(), 2); MessageBox::setNextAnswer(QMessageBox::No); @@ -729,7 +729,7 @@ void TestGui::testDeleteEntry() groupView, Qt::LeftButton); QCOMPARE(groupView->currentGroup()->name(), m_db->metadata()->recycleBin()->name()); - clickIndex(entryView->model()->index(0, 0), entryView, Qt::LeftButton); + clickIndex(entryView->model()->index(0, 1), entryView, Qt::LeftButton); MessageBox::setNextAnswer(QMessageBox::No); QTest::mouseClick(entryDeleteWidget, Qt::LeftButton); QCOMPARE(entryView->model()->rowCount(), 3); @@ -740,8 +740,8 @@ void TestGui::testDeleteEntry() QCOMPARE(entryView->model()->rowCount(), 2); QCOMPARE(m_db->metadata()->recycleBin()->entries().size(), 2); - clickIndex(entryView->model()->index(0, 0), entryView, Qt::LeftButton); - clickIndex(entryView->model()->index(1, 0), entryView, Qt::LeftButton, Qt::ControlModifier); + clickIndex(entryView->model()->index(0, 1), entryView, Qt::LeftButton); + clickIndex(entryView->model()->index(1, 1), entryView, Qt::LeftButton, Qt::ControlModifier); MessageBox::setNextAnswer(QMessageBox::Yes); QTest::mouseClick(entryDeleteWidget, Qt::LeftButton); QCOMPARE(entryView->model()->rowCount(), 0);