diff --git a/src/app/application.cpp b/src/app/application.cpp index 8955ceed5a95..12b5e067c7eb 100644 --- a/src/app/application.cpp +++ b/src/app/application.cpp @@ -81,6 +81,7 @@ #include "base/search/searchpluginmanager.h" #include "base/settingsstorage.h" #include "base/torrentfileswatcher.h" +#include "base/torrentgroup.h" #include "base/utils/fs.h" #include "base/utils/misc.h" #include "base/utils/os.h" @@ -306,6 +307,9 @@ Application::Application(int &argc, char **argv) initializeTranslation(); + // Load persisted torrent groups + TorrentGroupManager::instance()->load(); + connect(this, &QCoreApplication::aboutToQuit, this, &Application::cleanup); connect(m_instanceManager, &ApplicationInstanceManager::messageReceived, this, &Application::processMessage); #if defined(Q_OS_WIN) && !defined(DISABLE_GUI) @@ -1349,6 +1353,10 @@ void Application::cleanup() LogMsg(tr("qBittorrent termination initiated")); + // Persist torrent groups before tearing down preferences + if (TorrentGroupManager::instance()) + TorrentGroupManager::instance()->save(); + #ifndef DISABLE_GUI if (m_desktopIntegration) { diff --git a/src/base/CMakeLists.txt b/src/base/CMakeLists.txt index 93218100cc87..17e705e6b8f9 100644 --- a/src/base/CMakeLists.txt +++ b/src/base/CMakeLists.txt @@ -98,6 +98,7 @@ add_library(qbt_base STATIC settingsstorage.h tag.h tagset.h + torrentgroup.h torrentfileguard.h torrentfileswatcher.h torrentfilter.h @@ -198,6 +199,7 @@ add_library(qbt_base STATIC settingsstorage.cpp tag.cpp tagset.cpp + torrentgroup.cpp torrentfileguard.cpp torrentfileswatcher.cpp torrentfilter.cpp diff --git a/src/base/torrentgroup.cpp b/src/base/torrentgroup.cpp new file mode 100644 index 000000000000..02ccddca11f7 --- /dev/null +++ b/src/base/torrentgroup.cpp @@ -0,0 +1,216 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2025 AlfEspadero + * + * 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 + * of the License, or (at your option) any later version. + * + * 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, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#include "torrentgroup.h" + +#include +#include +#include + +#include "base/settingvalue.h" +#include "base/bittorrent/infohash.h" + +namespace +{ + const QString kPrefKey = QStringLiteral("TorrentGroups/Groups"); +} + +TorrentGroupManager *TorrentGroupManager::m_instance = nullptr; + +TorrentGroupManager::TorrentGroupManager(QObject *parent) + : QObject(parent) +{ +} + +TorrentGroupManager *TorrentGroupManager::instance() +{ + static TorrentGroupManager guard; // static lifetime + if (!m_instance) + m_instance = &guard; + return m_instance; +} + +QList TorrentGroupManager::groups() const +{ + return m_groups.values(); +} + +bool TorrentGroupManager::hasGroup(const QString &name) const +{ + return m_groups.contains(name); +} + +TorrentGroup TorrentGroupManager::group(const QString &name) const +{ + return m_groups.value(name, {}); +} + +bool TorrentGroupManager::createGroup(const QString &name, const QSet &initialMembers) +{ + const QString trimmed = name.trimmed(); + if (trimmed.isEmpty() || hasGroup(trimmed)) + return false; + TorrentGroup g; + g.name = trimmed; + g.members = initialMembers; + m_groups.insert(trimmed, g); + emit groupsChanged(); + if (!initialMembers.isEmpty()) + emit groupMembershipChanged(trimmed); + return true; +} + +bool TorrentGroupManager::renameGroup(const QString &oldName, const QString &newName) +{ + if (!hasGroup(oldName)) + return false; + const QString trimmed = newName.trimmed(); + if (trimmed.isEmpty() || hasGroup(trimmed)) + return false; + TorrentGroup g = m_groups.take(oldName); + g.name = trimmed; + m_groups.insert(trimmed, g); + // migrate expanded state if needed + if (m_expandedGroups.contains(oldName)) + { + m_expandedGroups.removeAll(oldName); + if (!m_expandedGroups.contains(trimmed)) + m_expandedGroups << trimmed; + save(); // persist change including expansion mapping + } + emit groupsChanged(); + return true; +} + +bool TorrentGroupManager::deleteGroup(const QString &name) +{ + if (!hasGroup(name)) + return false; + m_groups.remove(name); + emit groupsChanged(); + return true; +} + +bool TorrentGroupManager::addMembers(const QString &groupName, const QSet &members) +{ + if (!hasGroup(groupName) || members.isEmpty()) + return false; + TorrentGroup &g = m_groups[groupName]; + const int oldSize = g.members.size(); + g.members.unite(members); + if (g.members.size() != oldSize) + emit groupMembershipChanged(groupName); + return true; +} + +bool TorrentGroupManager::removeMembers(const QString &groupName, const QSet &members) +{ + if (!hasGroup(groupName) || members.isEmpty()) + return false; + TorrentGroup &g = m_groups[groupName]; + bool changed = false; + for (const BitTorrent::TorrentID &id : members) + changed |= g.members.remove(id) > 0; + if (changed) + emit groupMembershipChanged(groupName); + return changed; +} + +QString TorrentGroupManager::groupOf(const BitTorrent::TorrentID &id) const +{ + for (const TorrentGroup &g : m_groups) + { + if (g.members.contains(id)) + return g.name; + } + return {}; +} + +void TorrentGroupManager::load() +{ + m_groups.clear(); + m_expandedGroups.clear(); + SettingValue rawSetting {kPrefKey}; + const QByteArray raw = rawSetting.get(); + if (raw.isEmpty()) + return; + const QJsonDocument doc = QJsonDocument::fromJson(raw); + if (!doc.isObject()) + return; + const QJsonObject root = doc.object(); + const QJsonArray groupsArr = root.value(QStringLiteral("groups")).toArray(); + for (const QJsonValue &val : groupsArr) + { + if (!val.isObject()) + continue; + const QJsonObject obj = val.toObject(); + const QString name = obj.value(QStringLiteral("name")).toString(); + if (name.trimmed().isEmpty()) + continue; + TorrentGroup g; + g.name = name; + const QJsonArray memArr = obj.value(QStringLiteral("members")).toArray(); + for (const QJsonValue &mVal : memArr) + g.members.insert(BitTorrent::TorrentID::fromString(mVal.toString())); + m_groups.insert(g.name, g); + } + const QJsonArray expandedArr = root.value(QStringLiteral("expanded")).toArray(); + for (const QJsonValue &v : expandedArr) + m_expandedGroups << v.toString(); + emit groupsChanged(); +} + +void TorrentGroupManager::save() const +{ + QJsonArray groupsArr; + for (const TorrentGroup &g : m_groups) + { + QJsonObject obj; + obj.insert(QStringLiteral("name"), g.name); + QJsonArray memArr; + for (const BitTorrent::TorrentID &id : g.members) + memArr.append(id.toString()); + obj.insert(QStringLiteral("members"), memArr); + groupsArr.append(obj); + } + QJsonArray expandedArr; + for (const QString &n : m_expandedGroups) + expandedArr.append(n); + QJsonObject root; + root.insert(QStringLiteral("groups"), groupsArr); + root.insert(QStringLiteral("expanded"), expandedArr); + const QJsonDocument doc(root); + SettingValue rawSetting {kPrefKey}; + rawSetting = doc.toJson(QJsonDocument::Compact); +} + +void TorrentGroupManager::setExpandedGroups(const QStringList &names) +{ + m_expandedGroups = names; + save(); // persist immediately for now +} diff --git a/src/base/torrentgroup.h b/src/base/torrentgroup.h new file mode 100644 index 000000000000..dbd7b61bf715 --- /dev/null +++ b/src/base/torrentgroup.h @@ -0,0 +1,86 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2025 AlfEspadero + * + * 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 + * of the License, or (at your option) any later version. + * + * 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, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#pragma once + +#include +#include +#include +#include + +#include "base/bittorrent/infohash.h" // for BitTorrent::TorrentID + +struct TorrentGroup +{ + QString name; // unique + QSet members; + bool isValid() const + { + return !name.trimmed().isEmpty(); + } +}; + +class TorrentGroupManager final : public QObject +{ + Q_OBJECT + Q_DISABLE_COPY_MOVE(TorrentGroupManager) + + explicit TorrentGroupManager(QObject *parent = nullptr); +public: + static TorrentGroupManager *instance(); + + QList groups() const; + bool hasGroup(const QString &name) const; + TorrentGroup group(const QString &name) const; + + bool createGroup(const QString &name, const QSet &initialMembers = {}); + bool renameGroup(const QString &oldName, const QString &newName); + bool deleteGroup(const QString &name); + bool addMembers(const QString &groupName, const QSet &members); + bool removeMembers(const QString &groupName, const QSet &members); + + QString groupOf(const BitTorrent::TorrentID &id) const; // single group per torrent for MVP + + void load(); + void save() const; + + QStringList expandedGroups() const + { + return m_expandedGroups; + } + void setExpandedGroups(const QStringList &names); + +signals: + void groupsChanged(); + void groupMembershipChanged(const QString &groupName); + +private: + static TorrentGroupManager *m_instance; + QHash m_groups; // key: name + QStringList m_expandedGroups; +}; diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 94a6f065acb9..a55fdb415c40 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -131,6 +131,7 @@ add_library(qbt_gui STATIC transferlistfilters/trackersfilterwidget.h transferlistfilterswidget.h transferlistmodel.h + transferlistgroupmodel.h transferlistsortmodel.h transferlistwidget.h tristateaction.h @@ -232,6 +233,7 @@ add_library(qbt_gui STATIC transferlistfilters/trackersfilterwidget.cpp transferlistfilterswidget.cpp transferlistmodel.cpp + transferlistgroupmodel.cpp transferlistsortmodel.cpp transferlistwidget.cpp tristateaction.cpp diff --git a/src/gui/transferlistgroupmodel.cpp b/src/gui/transferlistgroupmodel.cpp new file mode 100644 index 000000000000..6633e55b37a5 --- /dev/null +++ b/src/gui/transferlistgroupmodel.cpp @@ -0,0 +1,395 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2025 AlfEspadero + * + * 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 + * of the License, or (at your option) any later version. + * + * 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, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#include "transferlistgroupmodel.h" + +#include +#include +#include + +#include "base/torrentgroup.h" +#include "transferlistmodel.h" +#include "uithememanager.h" +#include "base/utils/misc.h" +#include "base/utils/string.h" +#include "base/unicodestrings.h" +#include "base/types.h" + +// Helper to aggregate group stats (computed on demand; inexpensive for typical group sizes) +namespace +{ + struct GroupAggregates + { + qint64 wanted = 0; + qint64 completed = 0; + qint64 remaining = 0; + qint64 dlSpeed = 0; + qint64 upSpeed = 0; + qint64 seeds = 0; + qint64 totalSeeds = 0; + qint64 peers = 0; + qint64 totalPeers = 0; + qint64 downloaded = 0; + qint64 uploaded = 0; + qint64 totalSize = 0; + }; + + GroupAggregates computeAggregates(const QList &members) + { + GroupAggregates aggr; + for (BitTorrent::Torrent *t : members) + { + if (!t) + continue; + aggr.wanted += t->wantedSize(); + aggr.completed += t->completedSize(); + aggr.remaining += t->remainingSize(); + aggr.dlSpeed += t->downloadPayloadRate(); + aggr.upSpeed += t->uploadPayloadRate(); + aggr.seeds += t->seedsCount(); + aggr.totalSeeds += t->totalSeedsCount(); + aggr.peers += t->leechsCount(); + aggr.totalPeers += t->totalLeechersCount(); + aggr.downloaded += t->totalDownload(); + aggr.uploaded += t->totalUpload(); + aggr.totalSize += t->totalSize(); + } + return aggr; + } +} + +TransferListGroupModel::TransferListGroupModel(TransferListModel *source, QObject *parent) + : QAbstractItemModel(parent) + , m_source(source) +{ + connect(source, &QAbstractItemModel::dataChanged, this, &TransferListGroupModel::handleSourceChanged); + connect(source, &QAbstractItemModel::modelReset, this, &TransferListGroupModel::handleSourceReset); + connect(TorrentGroupManager::instance(), &TorrentGroupManager::groupsChanged, this, &TransferListGroupModel::rebuild); + connect(TorrentGroupManager::instance(), &TorrentGroupManager::groupMembershipChanged, this, &TransferListGroupModel::rebuild); + rebuild(); +} + +void TransferListGroupModel::rebuild() +{ + beginResetModel(); + m_groups.clear(); + m_ungrouped.clear(); + m_groupRowByName.clear(); + + const auto *mgr = TorrentGroupManager::instance(); + // Preserve insertion order of groups as defined by manager + const QList groups = mgr->groups(); + for (const auto &g : groups) + { + GroupItem gi; + gi.name = g.name; + gi.members.clear(); + m_groupRowByName.insert(g.name, m_groups.size()); + m_groups.push_back(gi); + } + + // Iterate torrents from source preserving original order for members & ungrouped + for (int r = 0; r < m_source->rowCount(); ++r) + { + BitTorrent::Torrent *t = m_source->torrentHandle(m_source->index(r, 0)); + if (!t) continue; + const QString groupName = mgr->groupOf(t->id()); + if (groupName.isEmpty()) + { + m_ungrouped.append(t); + } + else + { + const int idx = m_groupRowByName.value(groupName, -1); + if (idx >= 0) + m_groups[idx].members.append(t); + else + m_ungrouped.append(t); // fallback safety + } + } + endResetModel(); +} + +QModelIndex TransferListGroupModel::index(int row, int column, const QModelIndex &parent) const +{ + if ((row < 0) || (column < 0) || (column >= m_source->columnCount())) return {}; + + // Top-level + if (!parent.isValid()) + { + const int topLevelCount = m_groups.size() + m_ungrouped.size(); + if (row >= topLevelCount) return {}; + if (row < m_groups.size()) + return createIndex(row, column, static_cast(-1)); // group parent sentinel + // Ungrouped torrent leaf at top level: encode pointer + BitTorrent::Torrent *t = m_ungrouped[row - m_groups.size()]; + return createIndex(row, column, reinterpret_cast(t)); + } + + // Parent is a group + if (parent.internalId() == static_cast(-1)) + { + const int gIndex = parent.row(); + if ((gIndex < 0) || (gIndex >= m_groups.size())) return {}; + if (row >= m_groups[gIndex].members.size()) return {}; + BitTorrent::Torrent *t = m_groups[gIndex].members[row]; + return createIndex(row, column, reinterpret_cast(t)); + } + + // Leaf has no children + return {}; +} + +QModelIndex TransferListGroupModel::parent(const QModelIndex &child) const +{ + if (!child.isValid()) return {}; + const quintptr id = child.internalId(); + if (id == static_cast(-1)) return {}; // top-level group parent has no parent + + BitTorrent::Torrent *t = reinterpret_cast(id); + // Determine if torrent belongs to a group + const auto *mgr = TorrentGroupManager::instance(); + const QString groupName = mgr->groupOf(t->id()); + if (groupName.isEmpty()) return {}; // ungrouped => top-level leaf + const int gRow = m_groupRowByName.value(groupName, -1); + if (gRow < 0) return {}; + return createIndex(gRow, 0, static_cast(-1)); +} + +int TransferListGroupModel::rowCount(const QModelIndex &parent) const +{ + if (!parent.isValid()) + return m_groups.size() + m_ungrouped.size(); + if (parent.internalId() == static_cast(-1)) + { + const int gIndex = parent.row(); + if ((gIndex < 0) || (gIndex >= m_groups.size())) return 0; + return m_groups[gIndex].members.size(); + } + return 0; // leaf +} + +int TransferListGroupModel::columnCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return m_source->columnCount(); +} + +QVariant TransferListGroupModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) return {}; + const quintptr id = index.internalId(); + if (id == static_cast(-1)) + { + // Group parent row + const GroupItem &g = m_groups.value(index.row()); + if (role == Qt::BackgroundRole) + { + QColor base = QApplication::palette().color(QPalette::Base); + // Create a higher-contrast variant: lighten on dark themes, darken on light themes + if (base.lightness() < 128) + base = base.lighter(135); // noticeable light pad on dark background + else + base = base.darker(115); // subtle darker band on light background + return QBrush(base); + } + if ((role == Qt::DecorationRole) && (index.column() == TransferListModel::TR_NAME)) + { + const bool expanded = TorrentGroupManager::instance()->expandedGroups().contains(g.name); + return UIThemeManager::instance()->getIcon(expanded ? u"folder-documents"_s : u"directory"_s); + } + if ((role != Qt::DisplayRole) && (role != TransferListModel::UnderlyingDataRole)) + return {}; + + // Aggregate lazily for display / underlying roles + const GroupAggregates aggr = computeAggregates(g.members); + const int col = index.column(); + if (col == TransferListModel::TR_NAME) + { + if (role == Qt::DisplayRole) + return QStringLiteral("%1 (%2)").arg(g.name).arg(g.members.size()); + return g.name; // underlying for sorting by name + } + + // Provide aggregated numeric underlying values for logical sorting + if (role == TransferListModel::UnderlyingDataRole) + { + switch (col) + { + case TransferListModel::TR_SIZE: return aggr.wanted; + case TransferListModel::TR_TOTAL_SIZE: return aggr.totalSize; + case TransferListModel::TR_PROGRESS: return (aggr.wanted > 0) ? ((aggr.completed * 100.0) / aggr.wanted) : 0.0; + case TransferListModel::TR_SEEDS: return aggr.seeds; // primary value + case TransferListModel::TR_PEERS: return aggr.peers; + case TransferListModel::TR_DLSPEED: return aggr.dlSpeed; + case TransferListModel::TR_UPSPEED: return aggr.upSpeed; + case TransferListModel::TR_ETA: return (aggr.dlSpeed > 0) ? (aggr.remaining / qMax(1, aggr.dlSpeed)) : MAX_ETA; + case TransferListModel::TR_RATIO: return (aggr.downloaded > 0) ? (static_cast(aggr.uploaded) / aggr.downloaded) : BitTorrent::Torrent::MAX_RATIO; + case TransferListModel::TR_AMOUNT_DOWNLOADED: return aggr.downloaded; + case TransferListModel::TR_AMOUNT_UPLOADED: return aggr.uploaded; + case TransferListModel::TR_AMOUNT_LEFT: return aggr.remaining; + case TransferListModel::TR_COMPLETED: return aggr.completed; + default: return {}; // unsupported columns -> empty underlying data + } + } + + // DisplayRole formatting mirrors TransferListModel for key columns (basic subset) + switch (col) + { + case TransferListModel::TR_SIZE: + return Utils::Misc::friendlyUnit(aggr.wanted); + case TransferListModel::TR_TOTAL_SIZE: + return Utils::Misc::friendlyUnit(aggr.totalSize); + case TransferListModel::TR_PROGRESS: + { + if (aggr.wanted <= 0) return QString(); + const double p = static_cast(aggr.completed) / static_cast(aggr.wanted); + return (p >= 1.0) ? QStringLiteral("100%") : (Utils::String::fromDouble(p * 100.0, 1) + QLatin1Char('%')); + } + case TransferListModel::TR_SEEDS: + return QStringLiteral("%1 (%2)").arg(QString::number(aggr.seeds), QString::number(aggr.totalSeeds)); + case TransferListModel::TR_PEERS: + return QStringLiteral("%1 (%2)").arg(QString::number(aggr.peers), QString::number(aggr.totalPeers)); + case TransferListModel::TR_DLSPEED: + return Utils::Misc::friendlyUnit(aggr.dlSpeed, true); + case TransferListModel::TR_UPSPEED: + return Utils::Misc::friendlyUnit(aggr.upSpeed, true); + case TransferListModel::TR_ETA: + { + const qint64 eta = (aggr.dlSpeed > 0) ? (aggr.remaining / qMax(1, aggr.dlSpeed)) : MAX_ETA; + return Utils::Misc::userFriendlyDuration(eta, MAX_ETA); + } + case TransferListModel::TR_RATIO: + { + if (aggr.downloaded <= 0) return C_INFINITY; + const double r = static_cast(aggr.uploaded) / aggr.downloaded; + if ((static_cast(r) == -1) || (r >= BitTorrent::Torrent::MAX_RATIO)) return C_INFINITY; + return Utils::String::fromDouble(r, 2); + } + case TransferListModel::TR_AMOUNT_DOWNLOADED: + return Utils::Misc::friendlyUnit(aggr.downloaded); + case TransferListModel::TR_AMOUNT_UPLOADED: + return Utils::Misc::friendlyUnit(aggr.uploaded); + case TransferListModel::TR_AMOUNT_LEFT: + return Utils::Misc::friendlyUnit(aggr.remaining); + case TransferListModel::TR_COMPLETED: + return Utils::Misc::friendlyUnit(aggr.completed); + default: + return {}; // leave others blank for now + } + } + + BitTorrent::Torrent *t = reinterpret_cast(id); + if (!t) return {}; + const QModelIndex sourceIdx = m_source->indexForTorrent(t); + if (!sourceIdx.isValid()) return {}; + // Map to correct column in source + const QModelIndex mapped = m_source->index(sourceIdx.row(), index.column()); + if (role == Qt::BackgroundRole) + { + const auto *mgr = TorrentGroupManager::instance(); + if (!mgr->groupOf(t->id()).isEmpty()) + { + QColor base = QApplication::palette().color(QPalette::Base); + if (base.lightness() < 128) + base = base.lighter(120); + else + base = base.darker(105); + return QBrush(base); + } + } + return m_source->data(mapped, role); +} + +Qt::ItemFlags TransferListGroupModel::flags(const QModelIndex &index) const +{ + if (!index.isValid()) return Qt::NoItemFlags; + if (index.internalId() == static_cast(-1)) + return Qt::ItemIsEnabled | Qt::ItemIsSelectable; // group row selectable + // Forward from source + const auto *t = reinterpret_cast(index.internalId()); + if (!t) return Qt::NoItemFlags; + const QModelIndex sourceIdx = m_source->indexForTorrent(t); + if (!sourceIdx.isValid()) return Qt::NoItemFlags; + return m_source->flags(m_source->index(sourceIdx.row(), index.column())); +} + +QVariant TransferListGroupModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + return m_source->headerData(section, orientation, role); +} + +BitTorrent::Torrent *TransferListGroupModel::torrentHandle(const QModelIndex &index) const +{ + if (!index.isValid()) return nullptr; + const quintptr id = index.internalId(); + if (id == static_cast(-1)) return nullptr; // group parent + return reinterpret_cast(id); +} + +QString TransferListGroupModel::groupName(const QModelIndex &index) const +{ + if (!index.isValid() || (index.internalId() != static_cast(-1))) return {}; + if ((index.row() < 0) || (index.row() >= m_groups.size())) return {}; + return m_groups[index.row()].name; +} + +void TransferListGroupModel::setGroupExpanded(const QString &name, bool expanded) +{ + QStringList expandedList = TorrentGroupManager::instance()->expandedGroups(); + const bool already = expandedList.contains(name); + if (expanded && !already) + expandedList << name; + else if (!expanded && already) + expandedList.removeAll(name); + else + return; // no change + TorrentGroupManager::instance()->setExpandedGroups(expandedList); + // update icon + for (int i = 0; i < m_groups.size(); ++i) + { + if (m_groups[i].name == name) + { + const QModelIndex topLeft = createIndex(i, 0, static_cast(-1)); + emit dataChanged(topLeft, createIndex(i, columnCount({}) - 1, static_cast(-1)), {Qt::DecorationRole, Qt::DisplayRole}); + break; + } + } +} + +void TransferListGroupModel::handleSourceChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QList &roles) +{ + Q_UNUSED(topLeft); Q_UNUSED(bottomRight); Q_UNUSED(roles); + // Simplicity: full rebuild + rebuild(); +} + +void TransferListGroupModel::handleSourceReset() +{ + rebuild(); +} diff --git a/src/gui/transferlistgroupmodel.h b/src/gui/transferlistgroupmodel.h new file mode 100644 index 000000000000..7d65c4541846 --- /dev/null +++ b/src/gui/transferlistgroupmodel.h @@ -0,0 +1,80 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2025 AlfEspadero + * + * 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 + * of the License, or (at your option) any later version. + * + * 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, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#pragma once + +#include +#include +#include + +#include "transferlistmodel.h" +#include "base/torrentgroup.h" + +// A proxy-like aggregate model that exposes synthetic parent rows for groups. +// Child rows are underlying torrents. Delegate still works on DisplayRole. +class TransferListGroupModel final : public QAbstractItemModel +{ + Q_OBJECT + Q_DISABLE_COPY_MOVE(TransferListGroupModel) +public: + explicit TransferListGroupModel(TransferListModel *source, QObject *parent = nullptr); + + QModelIndex index(int row, int column, const QModelIndex &parent) const override; + QModelIndex parent(const QModelIndex &child) const override; + int rowCount(const QModelIndex &parent) const override; + int columnCount(const QModelIndex &parent) const override; + QVariant data(const QModelIndex &index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + Qt::ItemFlags flags(const QModelIndex &index) const override; + + void rebuild(); + + TransferListModel *sourceModel() const + { + return m_source; + } + BitTorrent::Torrent *torrentHandle(const QModelIndex &index) const; // nullptr for group parents + QString groupName(const QModelIndex &index) const; + void setGroupExpanded(const QString &name, bool expanded); + +private slots: + void handleSourceChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QList &roles = {}); + void handleSourceReset(); + +private: + struct GroupItem + { + QString name; + QList members; + }; + + TransferListModel *m_source; + QVector m_groups; // ordered + QList m_ungrouped; // top-level leaves after groups + QHash m_groupRowByName; // name -> row in m_groups +}; diff --git a/src/gui/transferlistmodel.cpp b/src/gui/transferlistmodel.cpp index f72443a7e399..3676a2942d55 100644 --- a/src/gui/transferlistmodel.cpp +++ b/src/gui/transferlistmodel.cpp @@ -44,6 +44,7 @@ #include "base/utils/misc.h" #include "base/utils/string.h" #include "uithememanager.h" +#include "base/torrentgroup.h" namespace { @@ -194,6 +195,7 @@ QVariant TransferListModel::headerData(const int section, const Qt::Orientation case TR_INFOHASH_V2: return tr("Info Hash v2", "i.e: torrent info hash v2"); case TR_REANNOUNCE: return tr("Reannounce In", "Indicates the time until next trackers reannounce"); case TR_PRIVATE: return tr("Private", "Flags private torrents"); + case TR_GROUP: return tr("Group", "Virtual group name aggregating multiple torrents"); default: return {}; } } @@ -443,6 +445,8 @@ QString TransferListModel::displayValue(const BitTorrent::Torrent *torrent, cons return reannounceString(torrent->nextAnnounce()); case TR_PRIVATE: return privateString(torrent->isPrivate(), torrent->hasMetadata()); + case TR_GROUP: + return TorrentGroupManager::instance()->groupOf(torrent->id()); } return {}; @@ -526,6 +530,8 @@ QVariant TransferListModel::internalValue(const BitTorrent::Torrent *torrent, co return torrent->nextAnnounce(); case TR_PRIVATE: return (torrent->hasMetadata() ? torrent->isPrivate() : QVariant()); + case TR_GROUP: + return TorrentGroupManager::instance()->groupOf(torrent->id()); } return {}; @@ -569,6 +575,8 @@ QVariant TransferListModel::data(const QModelIndex &index, const int role) const case TR_INFOHASH_V1: case TR_INFOHASH_V2: return displayValue(torrent, index.column()); + case TR_GROUP: + return displayValue(torrent, index.column()); } break; case Qt::TextAlignmentRole: @@ -665,6 +673,13 @@ BitTorrent::Torrent *TransferListModel::torrentHandle(const QModelIndex &index) return m_torrentList.value(index.row()); } +QModelIndex TransferListModel::indexForTorrent(const BitTorrent::Torrent *torrent) const +{ + const int row = m_torrentMap.value(const_cast(torrent), -1); + if (row < 0) return {}; + return index(row, 0); +} + void TransferListModel::handleTorrentAboutToBeRemoved(BitTorrent::Torrent *const torrent) { const int row = m_torrentMap.value(torrent, -1); diff --git a/src/gui/transferlistmodel.h b/src/gui/transferlistmodel.h index 6034e49406a5..a54fa2118fdb 100644 --- a/src/gui/transferlistmodel.h +++ b/src/gui/transferlistmodel.h @@ -87,6 +87,7 @@ class TransferListModel final : public QAbstractListModel TR_INFOHASH_V2, TR_REANNOUNCE, TR_PRIVATE, + TR_GROUP, // Virtual torrent group (experimental) NB_COLUMNS }; @@ -107,6 +108,7 @@ class TransferListModel final : public QAbstractListModel Qt::ItemFlags flags(const QModelIndex &index) const override; BitTorrent::Torrent *torrentHandle(const QModelIndex &index) const; + QModelIndex indexForTorrent(const BitTorrent::Torrent *torrent) const; // helper for grouping proxy private slots: void addTorrents(const QList &torrents); diff --git a/src/gui/transferlistsortmodel.cpp b/src/gui/transferlistsortmodel.cpp index ca222dd8ec77..30cd031e1e7e 100644 --- a/src/gui/transferlistsortmodel.cpp +++ b/src/gui/transferlistsortmodel.cpp @@ -36,6 +36,7 @@ #include "base/bittorrent/infohash.h" #include "base/bittorrent/torrent.h" #include "transferlistmodel.h" +#include "transferlistgroupmodel.h" namespace { @@ -319,11 +320,31 @@ bool TransferListSortModel::filterAcceptsRow(const int sourceRow, const QModelIn bool TransferListSortModel::matchFilter(const int sourceRow, const QModelIndex &sourceParent) const { - const auto *model = qobject_cast(sourceModel()); - if (!model) return false; - - const BitTorrent::Torrent *torrent = model->torrentHandle(model->index(sourceRow, 0, sourceParent)); - if (!torrent) return false; - - return m_filter.match(torrent); + // Support either direct list model or grouped model + if (auto *model = qobject_cast(sourceModel())) + { + const BitTorrent::Torrent *torrent = model->torrentHandle(model->index(sourceRow, 0, sourceParent)); + if (!torrent) return false; + return m_filter.match(torrent); + } + if (auto *groupModel = qobject_cast(sourceModel())) + { + const QModelIndex idx = groupModel->index(sourceRow, 0, sourceParent); + const BitTorrent::Torrent *torrent = groupModel->torrentHandle(idx); + if (!torrent) + { + // group parent: accept if any child matches (iterate children) + const int childCount = groupModel->rowCount(idx); + for (int i = 0; i < childCount; ++i) + { + const QModelIndex child = groupModel->index(i, 0, idx); + const BitTorrent::Torrent *childTorrent = groupModel->torrentHandle(child); + if (childTorrent && m_filter.match(childTorrent)) + return true; + } + return false; + } + return m_filter.match(torrent); + } + return false; } diff --git a/src/gui/transferlistwidget.cpp b/src/gui/transferlistwidget.cpp index 35c53e252b79..95abc96e6b72 100644 --- a/src/gui/transferlistwidget.cpp +++ b/src/gui/transferlistwidget.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -71,6 +72,8 @@ #include "transferlistsortmodel.h" #include "tristateaction.h" #include "uithememanager.h" +#include "transferlistgroupmodel.h" +#include "base/torrentgroup.h" #include "utils.h" #include "utils/keysequence.h" @@ -133,6 +136,11 @@ TransferListWidget::TransferListWidget(IGUIApplication *app, QWidget *parent) m_sortFilterModel->setSortRole(TransferListModel::UnderlyingDataRole); setModel(m_sortFilterModel); + // When groups change, we rebuild or install a group model wrapper. + connect(TorrentGroupManager::instance(), &TorrentGroupManager::groupsChanged, this, &TransferListWidget::updateGroupModel); + // Initial install if groups already exist (loaded from settings before widget constructed) + updateGroupModel(); + // Visual settings setUniformRowHeights(true); setRootIsDecorated(false); @@ -152,6 +160,22 @@ TransferListWidget::TransferListWidget(IGUIApplication *app, QWidget *parent) header()->setStretchLastSection(false); header()->setTextElideMode(Qt::ElideRight); + // Track expansion to persist (do after potential initial expansion restoration) + connect(this, &QTreeView::expanded, this, [this](const QModelIndex &proxyIdx) + { + if (!m_groupModel) return; + QModelIndex src = m_sortFilterModel->mapToSource(proxyIdx); + if ((src.model() == m_groupModel) && !m_groupModel->torrentHandle(src)) + m_groupModel->setGroupExpanded(m_groupModel->groupName(src), true); + }); + connect(this, &QTreeView::collapsed, this, [this](const QModelIndex &proxyIdx) + { + if (!m_groupModel) return; + QModelIndex src = m_sortFilterModel->mapToSource(proxyIdx); + if ((src.model() == m_groupModel) && !m_groupModel->torrentHandle(src)) + m_groupModel->setGroupExpanded(m_groupModel->groupName(src), false); + }); + // Default hidden columns if (!columnLoaded) { @@ -178,6 +202,7 @@ TransferListWidget::TransferListWidget(IGUIApplication *app, QWidget *parent) setColumnHidden(TransferListModel::TR_TOTAL_SIZE, true); setColumnHidden(TransferListModel::TR_REANNOUNCE, true); setColumnHidden(TransferListModel::TR_PRIVATE, true); + setColumnHidden(TransferListModel::TR_GROUP, true); } //Ensure that at least one column is visible at all times @@ -205,7 +230,7 @@ TransferListWidget::TransferListWidget(IGUIApplication *app, QWidget *parent) setContextMenuPolicy(Qt::CustomContextMenu); // Listen for list events - connect(this, &QAbstractItemView::doubleClicked, this, &TransferListWidget::torrentDoubleClicked); + connect(this, &QAbstractItemView::doubleClicked, this, &TransferListWidget::torrentDoubleClickedIndex); connect(this, &QWidget::customContextMenuRequested, this, &TransferListWidget::displayListMenu); header()->setContextMenuPolicy(Qt::CustomContextMenu); connect(header(), &QWidget::customContextMenuRequested, this, &TransferListWidget::displayColumnHeaderMenu); @@ -270,16 +295,31 @@ QModelIndex TransferListWidget::mapFromSource(const QModelIndex &index) const return m_sortFilterModel->mapFromSource(index); } -void TransferListWidget::torrentDoubleClicked() +void TransferListWidget::torrentDoubleClickedIndex(const QModelIndex &proxyIndex) { - const QModelIndexList selectedIndexes = selectionModel()->selectedRows(); - if ((selectedIndexes.size() != 1) || !selectedIndexes.first().isValid()) - return; - - const QModelIndex index = m_listModel->index(mapToSource(selectedIndexes.first()).row()); - BitTorrent::Torrent *const torrent = m_listModel->torrentHandle(index); - if (!torrent) - return; + QModelIndex targetProxyIndex = proxyIndex; + if (!targetProxyIndex.isValid()) return; + QModelIndex sourceIndex = m_sortFilterModel->mapToSource(targetProxyIndex); + BitTorrent::Torrent *torrent = nullptr; + if (m_groupModel && (sourceIndex.model() == m_groupModel)) + { + // If it's a group parent toggle expand/collapse + if (!m_groupModel->torrentHandle(sourceIndex)) + { + const QModelIndex firstColIdx = targetProxyIndex.sibling(targetProxyIndex.row(), 0); + setExpanded(firstColIdx, !isExpanded(firstColIdx)); + return; + } + torrent = m_groupModel->torrentHandle(sourceIndex); + // Map further down to base model + sourceIndex = m_listModel->indexForTorrent(torrent); + } + else + { + // Direct list model scenario + torrent = m_listModel->torrentHandle(sourceIndex); + } + if (!torrent) return; int action; if (torrent->isFinished()) @@ -314,17 +354,39 @@ void TransferListWidget::torrentDoubleClicked() } } +void TransferListWidget::torrentDoubleClicked() +{ + const QModelIndexList selectedIndexes = selectionModel()->selectedRows(); + if ((selectedIndexes.size() != 1) || !selectedIndexes.first().isValid()) return; + torrentDoubleClickedIndex(selectedIndexes.first()); +} + QList TransferListWidget::getSelectedTorrents() const { const QModelIndexList selectedRows = selectionModel()->selectedRows(); QList torrents; torrents.reserve(selectedRows.size()); - for (const QModelIndex &index : selectedRows) - torrents << m_listModel->torrentHandle(mapToSource(index)); + for (const QModelIndex &idx : selectedRows) + { + if (BitTorrent::Torrent *t = resolveTorrent(idx)) torrents << t; + } return torrents; } +BitTorrent::Torrent *TransferListWidget::resolveTorrent(const QModelIndex &proxyIndex) const +{ + if (!proxyIndex.isValid()) return nullptr; + QModelIndex src = m_sortFilterModel->mapToSource(proxyIndex); + if (m_groupModel && (src.model() == m_groupModel)) + { + BitTorrent::Torrent *t = m_groupModel->torrentHandle(src); + if (!t) return nullptr; // group parent + return t; + } + return m_listModel->torrentHandle(src); +} + QList TransferListWidget::getVisibleTorrents() const { const int visibleTorrentsCount = m_sortFilterModel->rowCount(); @@ -680,6 +742,44 @@ int TransferListWidget::visibleColumnsCount() const return count; } +void TransferListWidget::updateGroupModel() +{ + const bool hasGroups = !TorrentGroupManager::instance()->groups().isEmpty(); + if (hasGroups) + { + if (!m_groupModel) + { + m_groupModel = new TransferListGroupModel(m_listModel, this); + } + else + { + m_groupModel->rebuild(); + } + m_sortFilterModel->setSourceModel(m_groupModel); // chain: group -> sort + setRootIsDecorated(true); + setItemsExpandable(true); + setExpandsOnDoubleClick(false); // we'll manage double-click manually + // Restore expansion state + const QStringList expanded = TorrentGroupManager::instance()->expandedGroups(); + for (int r = 0; r < m_groupModel->rowCount({}); ++r) + { + const QModelIndex srcIdx = m_groupModel->index(r, 0, {}); + if (expanded.contains(m_groupModel->groupName(srcIdx))) + expand(m_sortFilterModel->mapFromSource(srcIdx)); + } + } + else + { + if (m_groupModel) + { + m_sortFilterModel->setSourceModel(m_listModel); + setRootIsDecorated(false); + setItemsExpandable(false); + setExpandsOnDoubleClick(true); + } + } +} + // hide/show columns menu void TransferListWidget::displayColumnHeaderMenu() { @@ -911,13 +1011,12 @@ void TransferListWidget::applyToSelectedTorrents(const std::functionselectedRows()); - for (const QModelIndex &index : sourceRows) - { - BitTorrent::Torrent *const torrent = m_listModel->torrentHandle(index); - Q_ASSERT(torrent); - fn(torrent); - } + const QModelIndexList selected = selectionModel()->selectedRows(); + QList torrents; + torrents.reserve(selected.size()); + for (const QModelIndex &proxy : selected) + if (BitTorrent::Torrent *t = resolveTorrent(proxy)) torrents << t; + for (BitTorrent::Torrent *t : torrents) fn(t); } void TransferListWidget::renameSelectedTorrent() @@ -942,6 +1041,102 @@ void TransferListWidget::renameSelectedTorrent() } } +void TransferListWidget::createGroupFromSelection() +{ + const QList torrents = getSelectedTorrents(); + if (torrents.isEmpty()) return; + bool ok = false; + const QString name = AutoExpandableDialog::getText(this, tr("Create Group"), tr("Group name:"), QLineEdit::Normal, {}, &ok).trimmed(); + if (!ok || name.isEmpty()) return; + QSet ids; + for (const BitTorrent::Torrent *t : torrents) ids.insert(t->id()); + if (!TorrentGroupManager::instance()->createGroup(name, ids)) + QMessageBox::warning(this, tr("Group"), tr("Failed to create group (duplicate name?).")); + else + TorrentGroupManager::instance()->save(); +} + +namespace +{ + QStringList groupNames() + { + QStringList list; + for (const auto &g : TorrentGroupManager::instance()->groups()) + list << g.name; + return list; + } +} + +void TransferListWidget::addSelectionToGroup() +{ + const QList torrents = getSelectedTorrents(); + if (torrents.isEmpty()) return; + const QStringList names = groupNames(); + if (names.isEmpty()) return; // no groups yet + bool ok = false; + const QString name = QInputDialog::getItem(this, tr("Add to Group"), tr("Select group:"), names, 0, false, &ok).trimmed(); + if (!ok || name.isEmpty()) return; + QSet ids; for (const BitTorrent::Torrent *t : torrents) ids.insert(t->id()); + TorrentGroupManager::instance()->addMembers(name, ids); + TorrentGroupManager::instance()->save(); +} + +void TransferListWidget::removeSelectionFromGroup() +{ + const QList torrents = getSelectedTorrents(); + if (torrents.isEmpty()) return; + // Build per-group removal list automatically; skip ungrouped torrents silently + QHash> toRemove; // groupName -> ids + auto *mgr = TorrentGroupManager::instance(); + for (const BitTorrent::Torrent *t : torrents) + { + const QString g = mgr->groupOf(t->id()); + if (!g.isEmpty()) + toRemove[g].insert(t->id()); + } + if (toRemove.isEmpty()) return; // nothing to do + for (auto it = toRemove.cbegin(); it != toRemove.cend(); ++it) + mgr->removeMembers(it.key(), it.value()); + mgr->save(); +} + +void TransferListWidget::deleteGroup() +{ + QString name; + if (!m_contextGroupName.isEmpty()) + name = m_contextGroupName; + else + { + const QStringList names = [&]{ QStringList list; for (const auto &g : TorrentGroupManager::instance()->groups()) list << g.name; return list; }(); + if (names.isEmpty()) return; + bool ok = false; + name = QInputDialog::getItem(this, tr("Delete Group"), tr("Select group:"), names, 0, false, &ok).trimmed(); + if (!ok || name.isEmpty()) return; + } + if (TorrentGroupManager::instance()->deleteGroup(name)) + TorrentGroupManager::instance()->save(); +} + +void TransferListWidget::renameGroup() +{ + QString oldName; + if (!m_contextGroupName.isEmpty()) + oldName = m_contextGroupName; + else + { + const QStringList names = [&]{ QStringList list; for (const auto &g : TorrentGroupManager::instance()->groups()) list << g.name; return list; }(); + if (names.isEmpty()) return; + bool ok = false; + oldName = QInputDialog::getItem(this, tr("Rename Group"), tr("Select group:"), names, 0, false, &ok).trimmed(); + if (!ok || oldName.isEmpty()) return; + } + bool ok = false; + const QString newName = AutoExpandableDialog::getText(this, tr("Rename Group"), tr("New name:"), QLineEdit::Normal, oldName, &ok).trimmed(); + if (!ok || newName.isEmpty()) return; + if (TorrentGroupManager::instance()->renameGroup(oldName, newName)) + TorrentGroupManager::instance()->save(); +} + void TransferListWidget::setSelectionCategory(const QString &category) { applyToSelectedTorrents([&category](BitTorrent::Torrent *torrent) { torrent->setCategory(category); }); @@ -967,13 +1162,51 @@ void TransferListWidget::displayListMenu() const QModelIndexList selectedIndexes = selectionModel()->selectedRows(); if (selectedIndexes.isEmpty()) return; + // Determine if selection is exclusively group parents before constructing large torrent menu + bool anyTorrent = false; + bool anyGroupParent = false; + m_contextGroupName.clear(); + for (const QModelIndex &idx : selectedIndexes) + { + if (BitTorrent::Torrent *t = resolveTorrent(idx)) + { Q_UNUSED(t); anyTorrent = true; } + else + { + QModelIndex src = m_sortFilterModel->mapToSource(idx); + if (m_groupModel && (src.model() == m_groupModel)) + { + const QString gName = m_groupModel->groupName(src); + if (!gName.isEmpty()) + { + anyGroupParent = true; + if (selectedIndexes.size() == 1) + m_contextGroupName = gName; + } + } + } + } + const bool onlyGroups = anyGroupParent && !anyTorrent; + + if (onlyGroups) + { + auto *groupMenu = new QMenu(this); + groupMenu->setAttribute(Qt::WA_DeleteOnClose); + auto *actionRenameGroup = new QAction(tr("Rename group"), groupMenu); + actionRenameGroup->setEnabled(selectedIndexes.size() == 1); + connect(actionRenameGroup, &QAction::triggered, this, &TransferListWidget::renameGroup); + groupMenu->addAction(actionRenameGroup); + auto *actionDeleteGroup = new QAction(tr("Delete group"), groupMenu); + connect(actionDeleteGroup, &QAction::triggered, this, &TransferListWidget::deleteGroup); + groupMenu->addAction(actionDeleteGroup); + groupMenu->popup(QCursor::pos()); + return; + } auto *listMenu = new QMenu(this); listMenu->setAttribute(Qt::WA_DeleteOnClose); listMenu->setToolTipsVisible(true); - // Create actions - + // Create torrent-related actions (since onlyGroups already handled) auto *actionStart = new QAction(UIThemeManager::instance()->getIcon(u"torrent-start"_s, u"media-playback-start"_s), tr("&Start", "Resume/start the torrent"), listMenu); connect(actionStart, &QAction::triggered, this, &TransferListWidget::startSelectedTorrents); auto *actionStop = new QAction(UIThemeManager::instance()->getIcon(u"torrent-stop"_s, u"media-playback-pause"_s), tr("Sto&p", "Stop the torrent"), listMenu); @@ -1031,9 +1264,9 @@ void TransferListWidget::displayListMenu() connect(actionEditTracker, &QAction::triggered, this, &TransferListWidget::editTorrentTrackers); auto *actionExportTorrent = new QAction(UIThemeManager::instance()->getIcon(u"edit-copy"_s), tr("E&xport .torrent..."), listMenu); connect(actionExportTorrent, &QAction::triggered, this, &TransferListWidget::exportTorrent); - // End of actions + // End of torrent actions - // Enable/disable stop/start action given the DL state + // Enable/disable stop/start action given the DL state (only if torrents selected exclusively) bool needsStop = false, needsStart = false, needsForce = false, needsPreview = false; bool allSameSuperSeeding = true; bool superSeedingMode = false; @@ -1050,9 +1283,11 @@ void TransferListWidget::displayListMenu() bool hasInfohashV1 = false, hasInfohashV2 = false; bool oneCanForceReannounce = false; - for (const QModelIndex &index : selectedIndexes) + if (!onlyGroups) { - const BitTorrent::Torrent *torrent = m_listModel->torrentHandle(mapToSource(index)); + for (const QModelIndex &index : selectedIndexes) + { + const BitTorrent::Torrent *torrent = resolveTorrent(index); if (!torrent) continue; @@ -1153,6 +1388,7 @@ void TransferListWidget::displayListMenu() { break; } + } } if (needsStart) @@ -1162,6 +1398,15 @@ void TransferListWidget::displayListMenu() if (needsForce) listMenu->addAction(actionForceStart); listMenu->addSeparator(); + auto *actionCreateGroup = new QAction(tr("Create group from selection"), listMenu); + connect(actionCreateGroup, &QAction::triggered, this, &TransferListWidget::createGroupFromSelection); + listMenu->addAction(actionCreateGroup); + auto *actionAddToGroup = new QAction(tr("Add selection to group"), listMenu); + connect(actionAddToGroup, &QAction::triggered, this, &TransferListWidget::addSelectionToGroup); + listMenu->addAction(actionAddToGroup); + auto *actionRemoveFromGroup = new QAction(tr("Remove selection from group"), listMenu); + connect(actionRemoveFromGroup, &QAction::triggered, this, &TransferListWidget::removeSelectionFromGroup); + listMenu->addAction(actionRemoveFromGroup); listMenu->addAction(actionDelete); listMenu->addSeparator(); listMenu->addAction(actionSetTorrentPath); @@ -1316,13 +1561,9 @@ void TransferListWidget::currentChanged(const QModelIndex ¤t, const QModel // navigate the torrent list with keyboard arrow keys. QTreeView::currentChanged(current, previous); - BitTorrent::Torrent *torrent = nullptr; + BitTorrent::Torrent *torrent = resolveTorrent(current); if (current.isValid()) - { - torrent = m_listModel->torrentHandle(mapToSource(current)); - // Fix scrolling to the lowermost visible torrent QMetaObject::invokeMethod(this, [this, current] { scrollTo(current); }, Qt::QueuedConnection); - } emit currentTorrentChanged(torrent); } diff --git a/src/gui/transferlistwidget.h b/src/gui/transferlistwidget.h index 28d85477e6e7..81dec2b027df 100644 --- a/src/gui/transferlistwidget.h +++ b/src/gui/transferlistwidget.h @@ -104,12 +104,18 @@ public slots: void applyTrackerFilter(const QSet &torrentIDs); void previewFile(const Path &filePath); void renameSelectedTorrent(); + void createGroupFromSelection(); + void addSelectionToGroup(); + void removeSelectionFromGroup(); + void deleteGroup(); + void renameGroup(); signals: void currentTorrentChanged(BitTorrent::Torrent *torrent); private slots: void torrentDoubleClicked(); + void torrentDoubleClickedIndex(const QModelIndex &proxyIndex); void displayListMenu(); void displayColumnHeaderMenu(); void currentChanged(const QModelIndex ¤t, const QModelIndex &previous) override; @@ -137,9 +143,13 @@ private slots: void confirmRemoveAllTagsForSelection(); TagSet askTagsForSelection(const QString &dialogTitle); void applyToSelectedTorrents(const std::function &fn); + BitTorrent::Torrent *resolveTorrent(const QModelIndex &proxyIndex) const; QList getVisibleTorrents() const; int visibleColumnsCount() const; + void updateGroupModel(); TransferListModel *m_listModel = nullptr; + class TransferListGroupModel *m_groupModel = nullptr; // optional hierarchical model TransferListSortModel *m_sortFilterModel = nullptr; + QString m_contextGroupName; // group name from last context menu invocation (group parent) };