From aef925743e13052ee76adf76bf909a72c8197ae6 Mon Sep 17 00:00:00 2001 From: Martchus Date: Sat, 18 May 2024 23:54:46 +0200 Subject: [PATCH] Allow selecting items in file browser This is the first step to allowing mass actions like ignoring/unignoring all selected items. --- syncthingconnector/syncthingconnection.h | 2 +- syncthingmodel/syncthingfilemodel.cpp | 137 ++++++++++++++++++++++- syncthingmodel/syncthingfilemodel.h | 15 +++ syncthingwidgets/CMakeLists.txt | 2 + syncthingwidgets/misc/otherdialogs.cpp | 8 ++ 5 files changed, 159 insertions(+), 5 deletions(-) diff --git a/syncthingconnector/syncthingconnection.h b/syncthingconnector/syncthingconnection.h index 82dada4..81cf377 100644 --- a/syncthingconnector/syncthingconnection.h +++ b/syncthingconnector/syncthingconnection.h @@ -80,7 +80,7 @@ struct LIB_SYNCTHING_CONNECTOR_EXPORT SyncthingItem { /// \brief Whether children are populated (depends on the requested level). bool childrenPopulated = false; /// \brief Whether the item is "checked"; not set by default but might be set to flag an item for some mass-action. - bool checked = false; + Qt::CheckState checked = Qt::Unchecked; /// \brief Whether the item is present in the Syncthing database. bool existsInDb = true; /// \brief Whether the item is present in the local file system. diff --git a/syncthingmodel/syncthingfilemodel.cpp b/syncthingmodel/syncthingfilemodel.cpp index 359e391..3a15674 100644 --- a/syncthingmodel/syncthingfilemodel.cpp +++ b/syncthingmodel/syncthingfilemodel.cpp @@ -9,6 +9,7 @@ #include +#include #include #include #include @@ -186,6 +187,27 @@ QVariant SyncthingFileModel::headerData(int section, Qt::Orientation orientation return QVariant(); } +Qt::ItemFlags SyncthingFileModel::flags(const QModelIndex &index) const +{ + auto f = QAbstractItemModel::flags(index); + if (index.isValid()) { + const auto *const item = reinterpret_cast(index.internalPointer()); + switch (item->type) { + case SyncthingItemType::File: + case SyncthingItemType::Symlink: + case SyncthingItemType::Error: + case SyncthingItemType::Loading: + f |= Qt::ItemNeverHasChildren; + break; + default:; + } + } + if (m_selectionMode) { + f |= Qt::ItemIsUserCheckable; + } + return f; +} + QVariant SyncthingFileModel::data(const QModelIndex &index, int role) const { if (!index.isValid()) { @@ -216,6 +238,15 @@ QVariant SyncthingFileModel::data(const QModelIndex &index, int role) const } } break; + case Qt::CheckStateRole: + if (!m_selectionMode) { + return QVariant(); + } + switch (index.column()) { + case 0: + return QVariant(item->checked); + } + break; case Qt::DecorationRole: { const auto &icons = commonForkAwesomeIcons(); switch (index.column()) { @@ -262,6 +293,7 @@ QVariant SyncthingFileModel::data(const QModelIndex &index, int role) const if (item->type == SyncthingItemType::Directory) { res << QStringLiteral("refresh"); } + res << QStringLiteral("toggle-selection"); if (!m_localPath.isEmpty() && item->isFilesystemItem()) { res << QStringLiteral("open") << QStringLiteral("copy-path"); } @@ -273,6 +305,7 @@ QVariant SyncthingFileModel::data(const QModelIndex &index, int role) const if (item->type == SyncthingItemType::Directory) { res << tr("Refresh"); } + res << (item->checked ? tr("Deselect") : tr("Select")); if (!m_localPath.isEmpty() && item->isFilesystemItem()) { res << (item->type == SyncthingItemType::Directory ? tr("Browse locally") : tr("Open local version")) << tr("Copy local path"); } @@ -284,6 +317,7 @@ QVariant SyncthingFileModel::data(const QModelIndex &index, int role) const if (item->type == SyncthingItemType::Directory) { res << QIcon::fromTheme(QStringLiteral("view-refresh"), QIcon(QStringLiteral(":/icons/hicolor/scalable/actions/view-refresh.svg"))); } + res << QIcon::fromTheme(QStringLiteral("edit-select")); if (!m_localPath.isEmpty() && item->isFilesystemItem()) { res << QIcon::fromTheme(QStringLiteral("folder"), QIcon(QStringLiteral(":/icons/hicolor/scalable/places/folder-open.svg"))); res << QIcon::fromTheme(QStringLiteral("edit-copy"), QIcon(QStringLiteral(":/icons/hicolor/scalable/places/edit-copy.svg"))); @@ -296,12 +330,72 @@ QVariant SyncthingFileModel::data(const QModelIndex &index, int role) const bool SyncthingFileModel::setData(const QModelIndex &index, const QVariant &value, int role) { - Q_UNUSED(index) - Q_UNUSED(value) - Q_UNUSED(role) + if (!index.isValid()) { + return false; + } + switch (role) { + case Qt::CheckStateRole: + setCheckState(index, static_cast(value.toInt())); + return true; + } return false; } +/// \brief Sets the whether the children of the specified \a item are checked. +static void setChildrenChecked(SyncthingItem *item, Qt::CheckState checkState) +{ + for (auto &childItem : item->children) { + setChildrenChecked(childItem.get(), childItem->checked = checkState); + } +} + +/// \brief Sets the check state of the specified \a index updating child and parent indexes accordingly. +void SyncthingFileModel::setCheckState(const QModelIndex &index, Qt::CheckState checkState) +{ + static const auto roles = QVector{ Qt::CheckStateRole }; + auto *const item = reinterpret_cast(index.internalPointer()); + auto affectedParentIndex = index; + item->checked = checkState; + + // set the checked state of child items as well + if (checkState != Qt::PartiallyChecked) { + setChildrenChecked(item, checkState); + } + + // update the checked state of parent items accordingly + for (auto *parentItem = item->parent; parentItem; parentItem = parentItem->parent) { + auto hasUncheckedSiblings = false; + auto hasCheckedSiblings = false; + for (auto &siblingItem : parentItem->children) { + switch (siblingItem->checked) { + case Qt::Unchecked: + hasUncheckedSiblings = true; + break; + case Qt::PartiallyChecked: + hasUncheckedSiblings = hasCheckedSiblings = true; + break; + case Qt::Checked: + hasCheckedSiblings = true; + } + if (hasUncheckedSiblings && hasCheckedSiblings) { + break; + } + } + auto parentChecked = hasUncheckedSiblings && hasCheckedSiblings ? Qt::PartiallyChecked : (hasUncheckedSiblings ? Qt::Unchecked : Qt::Checked); + if (parentItem->checked == parentChecked) { + break; + } + parentItem->checked = parentChecked; + affectedParentIndex = createIndex(static_cast(parentItem->index), 0, parentItem); + } + + // emit dataChanged() events + if (m_selectionMode) { + emit dataChanged(affectedParentIndex, index, roles); + invalidateAllIndicies(roles, affectedParentIndex); + } +} + int SyncthingFileModel::rowCount(const QModelIndex &parent) const { auto res = std::size_t(); @@ -345,6 +439,11 @@ void SyncthingFileModel::triggerAction(const QString &action, const QModelIndex { if (action == QLatin1String("refresh")) { fetchMore(index); + return; + } else if (action == QLatin1String("toggle-selection")) { + auto *const item = static_cast(index.internalPointer()); + setSelectionModeEnabled(true); + setData(index, item->checked != Qt::Checked ? Qt::Checked : Qt::Unchecked, Qt::CheckStateRole); } if (m_localPath.isEmpty()) { return; @@ -360,6 +459,30 @@ void SyncthingFileModel::triggerAction(const QString &action, const QModelIndex } } +QList SyncthingFileModel::selectionActions() +{ + auto res = QList(); + if (!m_selectionMode) { + return res; + } + auto *const discardAction = new QAction(tr("Discard selection"), this); + discardAction->setIcon(QIcon::fromTheme(QStringLiteral("edit-undo"))); + connect(discardAction, &QAction::triggered, this, [this] { + setSelectionModeEnabled(false); + setData(QModelIndex(), Qt::Unchecked, Qt::CheckStateRole); + }); + res << discardAction; + return res; +} + +void SyncthingFileModel::setSelectionModeEnabled(bool selectionModeEnabled) +{ + if (m_selectionMode != selectionModeEnabled) { + m_selectionMode = selectionModeEnabled; + invalidateAllIndicies(QVector{ Qt::CheckStateRole }); + } +} + void SyncthingFileModel::handleConfigInvalidated() { } @@ -431,6 +554,9 @@ void SyncthingFileModel::processFetchQueue(const QString &lastItemPath) refreshedIndex, 0, last < std::numeric_limits::max() ? static_cast(last) : std::numeric_limits::max()); refreshedItem->children = std::move(items); refreshedItem->childrenPopulated = true; + if (refreshedItem->checked == Qt::Checked) { + setChildrenChecked(refreshedItem, Qt::Checked); + } endInsertRows(); } if (refreshedItem->children.size() != previousChildCount) { @@ -510,7 +636,7 @@ void SyncthingFileModel::handleLocalLookupFinished() // mark items from the database query as locally existing if they do; mark items from local lookup as existing in the db if they do auto &localItems = *res; - for (auto &child : refreshedItem->children) { + for (auto &child : items) { auto localItemIter = localItems.find(child->name); if (localItemIter == localItems.end()) { continue; @@ -530,6 +656,9 @@ void SyncthingFileModel::handleLocalLookupFinished() auto &item = items.emplace_back(std::make_unique(std::move(localItem))); item->parent = refreshedItem; item->index = last; + if (refreshedItem->checked == Qt::Checked) { + setChildrenChecked(item.get(), item->checked = Qt::Checked); + } populatePath(item->path = refreshedItem->path % QChar('/') % item->name, item->children); endInsertRows(); } diff --git a/syncthingmodel/syncthingfilemodel.h b/syncthingmodel/syncthingfilemodel.h index e9e7be0..6cd6889 100644 --- a/syncthingmodel/syncthingfilemodel.h +++ b/syncthingmodel/syncthingfilemodel.h @@ -11,10 +11,14 @@ #include #include +QT_FORWARD_DECLARE_CLASS(QAction) + namespace Data { class LIB_SYNCTHING_MODEL_EXPORT SyncthingFileModel : public SyncthingModel { Q_OBJECT + Q_PROPERTY(bool selectionModeEnabled READ isSelectionModeEnabled WRITE setSelectionModeEnabled) + public: enum SyncthingFileModelRole { NameRole = SyncthingModelUserRole + 1, @@ -35,6 +39,7 @@ public Q_SLOTS: QModelIndex index(const QString &path) const; QModelIndex parent(const QModelIndex &child) const override; QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + Qt::ItemFlags flags(const QModelIndex &index) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; bool setData(const QModelIndex &index, const QVariant &value, int role) override; int rowCount(const QModelIndex &parent) const override; @@ -42,6 +47,9 @@ public Q_SLOTS: bool canFetchMore(const QModelIndex &parent) const override; void fetchMore(const QModelIndex &parent) override; void triggerAction(const QString &action, const QModelIndex &index); + QList selectionActions(); + bool isSelectionModeEnabled() const; + void setSelectionModeEnabled(bool selectionModeEnabled); public: QString path(const QModelIndex &path) const; @@ -57,6 +65,7 @@ private Q_SLOTS: void handleLocalLookupFinished(); private: + void setCheckState(const QModelIndex &index, Qt::CheckState checkState); void processFetchQueue(const QString &lastItemPath = QString()); private: @@ -76,8 +85,14 @@ private: QueryResult m_pendingRequest; QFutureWatcher m_localItemLookup; std::unique_ptr m_root; + bool m_selectionMode; }; +inline bool SyncthingFileModel::isSelectionModeEnabled() const +{ + return m_selectionMode; +} + } // namespace Data #endif // DATA_SYNCTHINGFILEMODEL_H diff --git a/syncthingwidgets/CMakeLists.txt b/syncthingwidgets/CMakeLists.txt index 02ec7bf..6bcb37d 100644 --- a/syncthingwidgets/CMakeLists.txt +++ b/syncthingwidgets/CMakeLists.txt @@ -71,6 +71,8 @@ set(REQUIRED_ICONS internet-web-browser system-run edit-paste + edit-select + edit-undo list-remove preferences-desktop-notification preferences-system-startup diff --git a/syncthingwidgets/misc/otherdialogs.cpp b/syncthingwidgets/misc/otherdialogs.cpp index 7908b65..9cd086d 100644 --- a/syncthingwidgets/misc/otherdialogs.cpp +++ b/syncthingwidgets/misc/otherdialogs.cpp @@ -114,6 +114,14 @@ QDialog *browseRemoteFilesDialog(Data::SyncthingConnection &connection, const Da &QAction::triggered, model, [model, action, index]() { model->triggerAction(action, index); }); ++actionIndex; } + if (const auto selectionActions = model->selectionActions(); !selectionActions.isEmpty()) { + menu.addSeparator(); + auto *const selectionMenu = menu.addMenu(QCoreApplication::translate("QtGui::OtherDialogs", "Selection")); + selectionMenu->addActions(selectionActions); + for (auto *const selectionAction : selectionActions) { + selectionAction->setParent(&menu); + } + } menu.exec(view->viewport()->mapToGlobal(pos)); });