diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7b82ff74..7f0fec46 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -177,6 +177,7 @@ target_sources(tremotesf_objects ui/itemmodels/stringlistmodel.h ui/itemmodels/torrentfilesmodelentry.h ui/itemmodels/torrentfilesproxymodel.h + ui/itemmodels/torrentfilestreebuilder.h ui/notificationscontroller.h ui/savewindowstatedispatcher.h ui/screens/aboutdialog.h @@ -493,6 +494,10 @@ if (BUILD_TESTING) add_executable(torrentfileparser_test torrentfileparser_test.cpp) add_test(NAME torrentfileparser_test COMMAND torrentfileparser_test WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/test-torrents") target_link_libraries(torrentfileparser_test PRIVATE tremotesf_objects Qt::Test) + + add_executable(torrentfilesmodel_test ui/itemmodels/torrentfilesmodel_test.cpp) + add_test(NAME torrentfilesmodel_test COMMAND torrentfilesmodel_test) + target_link_libraries(torrentfilesmodel_test PRIVATE tremotesf_objects Qt::Test) endif () set_common_options_on_targets() diff --git a/src/rpc/torrent.cpp b/src/rpc/torrent.cpp index 3a328b09..cb7ad781 100644 --- a/src/rpc/torrent.cpp +++ b/src/rpc/torrent.cpp @@ -802,7 +802,7 @@ namespace tremotesf { mFiles.reserve(static_cast(count)); changed.reserve(static_cast(count)); for (QJsonArray::size_type i = 0; i < count; ++i) { - mFiles.emplace_back(i, fileJsons[i].toObject(), fileStats[i].toObject()); + mFiles.emplace_back(fileJsons[i].toObject(), fileStats[i].toObject()); changed.push_back(static_cast(i)); } } else { diff --git a/src/rpc/torrentfile.cpp b/src/rpc/torrentfile.cpp index 31f6aaa8..d05ee7da 100644 --- a/src/rpc/torrentfile.cpp +++ b/src/rpc/torrentfile.cpp @@ -23,13 +23,8 @@ namespace tremotesf { ); } - TorrentFile::TorrentFile(int id, const QJsonObject& fileMap, const QJsonObject& fileStatsMap) - : id(id), size(fileMap.value("length"_L1).toInteger()) { - auto p = fileMap.value("name"_L1).toString().split('/', Qt::SkipEmptyParts); - path.reserve(static_cast(p.size())); - for (QString& part : p) { - path.push_back(std::move(part)); - } + TorrentFile::TorrentFile(const QJsonObject& fileMap, const QJsonObject& fileStatsMap) + : path(fileMap.value("name"_L1).toString()), size(fileMap.value("length"_L1).toInteger()) { update(fileStatsMap); } diff --git a/src/rpc/torrentfile.h b/src/rpc/torrentfile.h index f5fb0092..8694793b 100644 --- a/src/rpc/torrentfile.h +++ b/src/rpc/torrentfile.h @@ -5,7 +5,6 @@ #ifndef TREMOTESF_RPC_TORRENTFILE_H #define TREMOTESF_RPC_TORRENTFILE_H -#include #include #include @@ -18,16 +17,16 @@ namespace tremotesf { enum class Priority { Low, Normal, High }; Q_ENUM(Priority) - explicit TorrentFile(int id, const QJsonObject& fileMap, const QJsonObject& fileStatsMap); + explicit TorrentFile(const QJsonObject& fileMap, const QJsonObject& fileStatsMap); bool update(const QJsonObject& fileStatsMap); - int id{}; - - std::vector path{}; + QString path{}; qint64 size{}; qint64 completedSize{}; Priority priority{}; bool wanted{}; + + auto pathParts() const { return path.tokenize(u'/', Qt::SkipEmptyParts); } }; } diff --git a/src/ui/itemmodels/basetorrentfilesmodel.cpp b/src/ui/itemmodels/basetorrentfilesmodel.cpp index 3c68d11d..96cd9f13 100644 --- a/src/ui/itemmodels/basetorrentfilesmodel.cpp +++ b/src/ui/itemmodels/basetorrentfilesmodel.cpp @@ -4,11 +4,14 @@ #include "basetorrentfilesmodel.h" +#include + #include +#include #include +#include #include "desktoputils.h" -#include "itemlistupdater.h" #include "formatutils.h" namespace tremotesf { @@ -21,7 +24,7 @@ namespace tremotesf { if (!index.isValid()) { return {}; } - const TorrentFilesModelEntry* entry = static_cast(index.internalPointer()); + const auto* const entry = static_cast(index.internalPointer()); const Column column = mColumns.at(static_cast(index.column())); switch (role) { case Qt::CheckStateRole: @@ -120,81 +123,172 @@ namespace tremotesf { return false; } if (static_cast(index.column()) == Column::Name && role == Qt::CheckStateRole) { - setFileWanted(index, (value.toInt() == Qt::Checked)); + setFilesWanted({index}, (value.toInt() == Qt::Checked)); return true; } return false; } QModelIndex BaseTorrentFilesModel::index(int row, int column, const QModelIndex& parent) const { - const TorrentFilesModelDirectory* parentDirectory{}; - if (parent.isValid()) { - parentDirectory = static_cast(parent.internalPointer()); - } else if (mRootDirectory) { - parentDirectory = mRootDirectory.get(); - } else { + if (!parent.isValid()) { + if (row == 0 && mRootEntry) { + return createIndex(0, column, mRootEntry.get()); + } + return {}; + } + const auto* const parentEntry = static_cast(parent.internalPointer()); + if (!parentEntry->isDirectory()) { return {}; } - return createIndex(row, column, parentDirectory->children().at(static_cast(row)).get()); + return createIndex(row, column, &parentEntry->children().at(static_cast(row))); } QModelIndex BaseTorrentFilesModel::parent(const QModelIndex& child) const { if (!child.isValid()) { return {}; } - const auto* const parentDirectory = + const auto* const parentDirectoryEntry = static_cast(child.internalPointer())->parentDirectory(); - if (parentDirectory == mRootDirectory.get()) { + if (!parentDirectoryEntry) { return {}; } - return createIndex(parentDirectory->row(), 0, parentDirectory); + return createIndex(parentDirectoryEntry->row(), 0, parentDirectoryEntry); } int BaseTorrentFilesModel::rowCount(const QModelIndex& parent) const { if (parent.isValid()) { - const TorrentFilesModelEntry* entry = static_cast(parent.internalPointer()); + const auto* const entry = static_cast(parent.internalPointer()); if (entry->isDirectory()) { - return static_cast(static_cast(entry)->children().size()); + return static_cast(entry->children().size()); } - return 0; - } - if (mRootDirectory) { - return static_cast(mRootDirectory->children().size()); + } else if (mRootEntry) { + return 1; } return 0; } - void BaseTorrentFilesModel::setFileWanted(const QModelIndex& index, bool wanted) { - if (index.isValid()) { - static_cast(index.internalPointer())->setWanted(wanted); - updateDirectoryChildren(); + namespace { + class DataChangedDispatcher final { + public: + void add(const QModelIndex& parent, int row) { + // NOLINTNEXTLINE(clazy-detaching-member) + if (const auto found = mPendingSignals.find(parent); found != mPendingSignals.end()) { + found->push_back(row); + } else { + mPendingSignals.emplace(parent, std::vector{row}); + } + } + void dispatchSignals(QAbstractItemModel& model) { + for (auto&& [parent, rows] : mPendingSignals.asKeyValueRange()) { + if (rows.size() == 1) { + const int row = rows.front(); + emit model.dataChanged( + model.index(row, 0, parent), + model.index(row, model.columnCount() - 1, parent) + ); + continue; + } + std::ranges::sort(rows); + int firstRow = rows.front(); + int lastRow = firstRow; + const auto emitForRange = [&] { + emit model.dataChanged( + model.index(firstRow, 0, parent), + model.index(lastRow, model.columnCount() - 1, parent) + ); + }; + for (int row : rows | std::views::drop(1)) { + if (row != (lastRow + 1)) { + emitForRange(); + firstRow = row; + } + lastRow = row; + } + emitForRange(); + } + } + + private: + QHash> mPendingSignals{}; + }; + + void recalculateDirectoryAndItsParents( + TorrentFilesModelEntry* directory, QModelIndex index, DataChangedDispatcher& dispatcher + ) { + while (index.isValid()) { + if (!directory->recalculateFromChildren()) { + break; + } + dispatcher.add(index.parent(), index.row()); + directory = directory->parentDirectory(); + index = index.parent(); + } } - } - void BaseTorrentFilesModel::setFilesWanted(const QModelIndexList& indexes, bool wanted) { - for (const QModelIndex& index : indexes) { - if (index.isValid()) { - static_cast(index.internalPointer())->setWanted(wanted); + template UpdateState> + requires std::same_as, bool> + void updateDirectoryChildrenRecursively( + TorrentFilesModelEntry* directory, + const QModelIndex& directoryIndex, + UpdateState updateState, + BaseTorrentFilesModel& model, + DataChangedDispatcher& dispatcher + ) { + for (auto& entry : directory->children()) { + if (updateState(&entry)) { + dispatcher.add(directoryIndex, entry.row()); + } + if (entry.isDirectory()) { + updateDirectoryChildrenRecursively( + &entry, + model.index(entry.row(), 0, directoryIndex), + updateState, + model, + dispatcher + ); + } } } - updateDirectoryChildren(); - } - void BaseTorrentFilesModel::setFilePriority(const QModelIndex& index, TorrentFilesModelEntry::Priority priority) { - if (index.isValid()) { - static_cast(index.internalPointer())->setPriority(priority); - updateDirectoryChildren(); + template UpdateState> + requires std::same_as, bool> + void + setWantedOrPriority(const QModelIndexList& indexes, UpdateState updateState, BaseTorrentFilesModel& model) { + if (indexes.empty()) return; + if (!std::ranges::all_of(indexes, &QModelIndex::isValid)) return; + + DataChangedDispatcher dispatcher{}; + QSet> parentDirectoriesToRecalculate{}; + + for (const auto& index : indexes) { + auto* const entry = static_cast(index.internalPointer()); + if (updateState(entry)) { + dispatcher.add(index.parent(), index.row()); + if (entry->isDirectory()) { + updateDirectoryChildrenRecursively(entry, index, updateState, model, dispatcher); + } + parentDirectoriesToRecalculate.insert({entry->parentDirectory(), index.parent()}); + } + } + + for (const auto& [directory, index] : parentDirectoriesToRecalculate) { + recalculateDirectoryAndItsParents(directory, index, dispatcher); + } + + dispatcher.dispatchSignals(model); } } + void BaseTorrentFilesModel::setFilesWanted(const QModelIndexList& indexes, bool wanted) { + setWantedOrPriority(indexes, [&](TorrentFilesModelEntry* entry) { return entry->setWanted(wanted); }, *this); + } void BaseTorrentFilesModel::setFilesPriority(const QModelIndexList& indexes, TorrentFilesModelEntry::Priority priority) { - for (const QModelIndex& index : indexes) { - if (index.isValid()) { - static_cast(index.internalPointer())->setPriority(priority); - } - } - updateDirectoryChildren(); + setWantedOrPriority( + indexes, + [&](TorrentFilesModelEntry* entry) { return entry->setPriority(priority); }, + *this + ); } void BaseTorrentFilesModel::fileRenamed(TorrentFilesModelEntry* entry, const QString& newName) { @@ -202,33 +296,28 @@ namespace tremotesf { emit dataChanged(createIndex(entry->row(), 0, entry), createIndex(entry->row(), columnCount() - 1, entry)); } - void BaseTorrentFilesModel::updateDirectoryChildren(const QModelIndex& parent) { - const TorrentFilesModelDirectory* directory{}; - if (parent.isValid()) { - directory = static_cast(parent.internalPointer()); - } else if (mRootDirectory) { - directory = mRootDirectory.get(); - } else { - return; - } - - auto changedBatchProcessor = ItemBatchProcessor([&](size_t first, size_t last) { - emit dataChanged( - index(static_cast(first), 0, parent), - index(static_cast(last) - 1, columnCount() - 1, parent) - ); - }); - - for (auto& child : directory->children()) { - if (child->isChanged()) { - changedBatchProcessor.nextIndex(static_cast(child->row())); - if (child->isDirectory()) { - updateDirectoryChildren(index(child->row(), 0, parent)); - } else { - static_cast(child.get())->setChanged(false); + void BaseTorrentFilesModel::updateFiles( + std::span changedFiles, std::function&& updateFile + ) { + if (changedFiles.empty()) return; + + DataChangedDispatcher dispatcher{}; + + for (int index : changedFiles) { + const auto sIndex = static_cast(index); + auto* const file = mFiles.at(sIndex); + updateFile(sIndex, file); + auto* const parent = file->parentDirectory(); + const auto parentIndex = [&] { + if (!parent) { + return QModelIndex{}; } - } + return createIndex(parent->row(), 0, parent); + }(); + dispatcher.add(parentIndex, file->row()); + recalculateDirectoryAndItsParents(parent, parentIndex, dispatcher); } - changedBatchProcessor.commitIfNeeded(); + + dispatcher.dispatchSignals(*this); } } diff --git a/src/ui/itemmodels/basetorrentfilesmodel.h b/src/ui/itemmodels/basetorrentfilesmodel.h index 81571627..11e6810e 100644 --- a/src/ui/itemmodels/basetorrentfilesmodel.h +++ b/src/ui/itemmodels/basetorrentfilesmodel.h @@ -5,6 +5,7 @@ #ifndef TREMOTESF_BASETORRENTFILESMODEL_H #define TREMOTESF_BASETORRENTFILESMODEL_H +#include #include #include #include @@ -31,18 +32,19 @@ namespace tremotesf { QModelIndex parent(const QModelIndex& child) const override; int rowCount(const QModelIndex& parent = {}) const override; - virtual void setFileWanted(const QModelIndex& index, bool wanted); virtual void setFilesWanted(const QModelIndexList& indexes, bool wanted); - virtual void setFilePriority(const QModelIndex& index, TorrentFilesModelEntry::Priority priority); virtual void setFilesPriority(const QModelIndexList& indexes, TorrentFilesModelEntry::Priority priority); virtual void renameFile(const QModelIndex& index, const QString& newName) = 0; void fileRenamed(TorrentFilesModelEntry* entry, const QString& newName); protected: - void updateDirectoryChildren(const QModelIndex& parent = QModelIndex()); + void updateFiles( + std::span changedFiles, std::function&& updateFile + ); - std::shared_ptr mRootDirectory; + std::unique_ptr mRootEntry{}; + std::vector mFiles{}; private: const std::vector mColumns; diff --git a/src/ui/itemmodels/torrentfilesmodel_test.cpp b/src/ui/itemmodels/torrentfilesmodel_test.cpp new file mode 100644 index 00000000..8e0f40fe --- /dev/null +++ b/src/ui/itemmodels/torrentfilesmodel_test.cpp @@ -0,0 +1,650 @@ +// SPDX-FileCopyrightText: 2015-2025 Alexey Rochev +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#define QTEST_THROW_ON_FAIL + +#include +#include + +#include +#include + +#include "basetorrentfilesmodel.h" +#include "torrentfilestreebuilder.h" +#include "formatutils.h" +#include "log/log.h" + +#include + +SPECIALIZE_FORMATTER_FOR_QDEBUG(QVariant) + +using namespace Qt::StringLiterals; + +namespace tremotesf { + using formatutils::formatByteSize; + using formatutils::formatProgress; + + namespace { + QModelIndex indexForPath(QLatin1String path, QAbstractItemModel& model) { + QModelIndex index{}; + for (const auto& part : path.tokenize(u'/')) { + const auto childIndexes = + std::views::iota(0, model.rowCount(index)) | std::views::transform([&](int row) { + return model.index(row, static_cast(BaseTorrentFilesModel::Column::Name), index); + }); + const auto found = std::ranges::find(childIndexes, part, [&](const QModelIndex& childIndex) { + return childIndex.data().toString(); + }); + if (found == childIndexes.end()) { + warning().log("Did not find child with name {}", part); + QFAIL("Nope"); + } + index = *found; + } + return index; + } + + std::pair + expectedDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight) { + return {topLeft.siblingAtColumn(0), bottomRight.siblingAtColumn(bottomRight.model()->columnCount() - 1)}; + } + + std::pair expectedDataChanged(const QModelIndex& index) { + return expectedDataChanged(index, index); + } + + std::set> actualDataChanged(QSignalSpy& spy) { + return spy + | std::views::transform([](QList args) { + return std::pair{args.at(0).toModelIndex(), args.at(1).toModelIndex()}; + }) + | std::ranges::to(); + } + } + + class TestTorrentFilesModel : public BaseTorrentFilesModel { + Q_OBJECT + public: + TestTorrentFilesModel() + : BaseTorrentFilesModel( + {Column::Name, Column::Size, Column::ProgressBar, Column::Progress, Column::Priority} + ) {} + + void renameFile(const QModelIndex&, const QString&) override {} + + struct File { + QString path; + qint64 size; + qint64 completedSize; + TorrentFilesModelEntry::Priority priority; + bool wanted; + }; + + void populate() { + const std::array files{ + File{ + .path = "topdir/subdir1/subsubddir/file1", + .size = 666, + .completedSize = 0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .wanted = true + }, + File{ + .path = "topdir/subdir1/subsubddir/file2", + .size = 100000, + .completedSize = 4234, + .priority = TorrentFilesModelEntry::Priority::Low, + .wanted = false + }, + File{ + .path = "topdir/subdir2/file1", + .size = 3333333, + .completedSize = 0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .wanted = true + }, + File{ + .path = "topdir/subdir2/file2", + .size = 111, + .completedSize = 0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .wanted = true + } + }; + TorrentFilesTreeBuilder builder(files.size()); + for (const File& file : files) { + builder.addFile( + file.path.tokenize(u'/', Qt::SkipEmptyParts), + true, + file.size, + file.completedSize, + file.wanted, + file.priority + ); + } + builder.calculateDirectoriesRecursively(); + mRootEntry = std::move(builder.rootEntry); + mFiles = std::move(builder.files); + } + + using BaseTorrentFilesModel::updateFiles; + }; + + class TorrentFilesModelTest : public QObject { + Q_OBJECT + + private slots: + void checkInitialState() { + TestTorrentFilesModel model{}; + model.populate(); + + checkTree( + model, + {.name = "topdir"_L1, + .size = 3434110, + .progress = 4234.0 / 3434110.0, + .priority = TorrentFilesModelEntry::Priority::Mixed, + .checkState = Qt::CheckState::PartiallyChecked, + + .children = { + {.name = "subdir1"_L1, + .size = 100666, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Mixed, + .checkState = Qt::CheckState::PartiallyChecked, + + .children = + {{.name = "subsubddir"_L1, + .size = 100666, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Mixed, + .checkState = Qt::CheckState::PartiallyChecked, + + .children = + {{.name = "file1"_L1, + .size = 666, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked}, + {.name = "file2"_L1, + .size = 100000, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Low, + .checkState = Qt::CheckState::Unchecked}}}}}, + + {.name = "subdir2"_L1, + .size = 3333444, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked, + + .children = { + {.name = "file1"_L1, + .size = 3333333, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked}, + {.name = "file2"_L1, + .size = 111, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked} + }} + }} + ); + } + + void checkSetFilesWantedFromRoot() { + TestTorrentFilesModel model{}; + model.populate(); + + QSignalSpy dataChanged(&model, &QAbstractItemModel::dataChanged); + + model.setFilesWanted({model.index(0, 0)}, false); + + const auto actualSignals = actualDataChanged(dataChanged); + const auto expectedSignals = std::set{ + expectedDataChanged(indexForPath("topdir/subdir1/subsubddir/file1"_L1, model)), + expectedDataChanged(indexForPath("topdir/subdir1/subsubddir"_L1, model)), + expectedDataChanged( + indexForPath("topdir/subdir2/file1"_L1, model), + indexForPath("topdir/subdir2/file2"_L1, model) + ), + expectedDataChanged(indexForPath("topdir/subdir1"_L1, model), indexForPath("topdir/subdir2"_L1, model)), + expectedDataChanged(indexForPath("topdir"_L1, model)) + }; + + QCOMPARE(actualSignals, expectedSignals); + + checkTree( + model, + {.name = "topdir"_L1, + .size = 3434110, + .progress = 4234.0 / 3434110.0, + .priority = TorrentFilesModelEntry::Priority::Mixed, + .checkState = Qt::CheckState::Unchecked, + + .children = { + {.name = "subdir1"_L1, + .size = 100666, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Mixed, + .checkState = Qt::CheckState::Unchecked, + + .children = + {{.name = "subsubddir"_L1, + .size = 100666, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Mixed, + .checkState = Qt::CheckState::Unchecked, + + .children = + {{.name = "file1"_L1, + .size = 666, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Unchecked}, + {.name = "file2"_L1, + .size = 100000, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Low, + .checkState = Qt::CheckState::Unchecked}}}}}, + + {.name = "subdir2"_L1, + .size = 3333444, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Unchecked, + + .children = { + {.name = "file1"_L1, + .size = 3333333, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Unchecked}, + {.name = "file2"_L1, + .size = 111, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Unchecked} + }} + }} + ); + } + + void checkSetFilesWantedFromFile() { + TestTorrentFilesModel model{}; + model.populate(); + + QSignalSpy dataChanged(&model, &QAbstractItemModel::dataChanged); + + model.setFilesWanted({indexForPath("topdir/subdir1/subsubddir/file2"_L1, model)}, true); + + const auto actualSignals = actualDataChanged(dataChanged); + const auto expectedSignals = std::set{ + expectedDataChanged(indexForPath("topdir/subdir1/subsubddir/file2"_L1, model)), + expectedDataChanged(indexForPath("topdir/subdir1/subsubddir"_L1, model)), + expectedDataChanged(indexForPath("topdir/subdir1"_L1, model)), + expectedDataChanged(indexForPath("topdir"_L1, model)) + }; + QCOMPARE(actualSignals, expectedSignals); + + checkTree( + model, + {.name = "topdir"_L1, + .size = 3434110, + .progress = 4234.0 / 3434110.0, + .priority = TorrentFilesModelEntry::Priority::Mixed, + .checkState = Qt::CheckState::Checked, + + .children = { + {.name = "subdir1"_L1, + .size = 100666, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Mixed, + .checkState = Qt::CheckState::Checked, + + .children = + {{.name = "subsubddir"_L1, + .size = 100666, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Mixed, + .checkState = Qt::CheckState::Checked, + + .children = + {{.name = "file1"_L1, + .size = 666, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked}, + {.name = "file2"_L1, + .size = 100000, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Low, + .checkState = Qt::CheckState::Checked}}}}}, + + {.name = "subdir2"_L1, + .size = 3333444, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked, + + .children = { + {.name = "file1"_L1, + .size = 3333333, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked}, + {.name = "file2"_L1, + .size = 111, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked} + }} + }} + ); + } + + void checkSetFilesPriorityFromRoot() { + TestTorrentFilesModel model{}; + model.populate(); + + QSignalSpy dataChanged(&model, &QAbstractItemModel::dataChanged); + + model.setFilesPriority({model.index(0, 0)}, TorrentFilesModelEntry::Priority::Low); + + const auto actualSignals = actualDataChanged(dataChanged); + const auto expectedSignals = std::set{ + expectedDataChanged(indexForPath("topdir/subdir1/subsubddir/file1"_L1, model)), + expectedDataChanged(indexForPath("topdir/subdir1/subsubddir"_L1, model)), + expectedDataChanged( + indexForPath("topdir/subdir2/file1"_L1, model), + indexForPath("topdir/subdir2/file2"_L1, model) + ), + expectedDataChanged(indexForPath("topdir/subdir1"_L1, model), indexForPath("topdir/subdir2"_L1, model)), + expectedDataChanged(indexForPath("topdir"_L1, model)) + }; + QCOMPARE(actualSignals, expectedSignals); + + checkTree( + model, + {.name = "topdir"_L1, + .size = 3434110, + .progress = 4234.0 / 3434110.0, + .priority = TorrentFilesModelEntry::Priority::Low, + .checkState = Qt::CheckState::PartiallyChecked, + + .children = { + {.name = "subdir1"_L1, + .size = 100666, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Low, + .checkState = Qt::CheckState::PartiallyChecked, + + .children = + {{.name = "subsubddir"_L1, + .size = 100666, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Low, + .checkState = Qt::CheckState::PartiallyChecked, + + .children = + {{.name = "file1"_L1, + .size = 666, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Low, + .checkState = Qt::CheckState::Checked}, + {.name = "file2"_L1, + .size = 100000, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Low, + .checkState = Qt::CheckState::Unchecked}}}}}, + + {.name = "subdir2"_L1, + .size = 3333444, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Low, + .checkState = Qt::CheckState::Checked, + + .children = { + {.name = "file1"_L1, + .size = 3333333, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Low, + .checkState = Qt::CheckState::Checked}, + {.name = "file2"_L1, + .size = 111, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Low, + .checkState = Qt::CheckState::Checked} + }} + }} + ); + } + + void checkSetFilesPriorityFromFile() { + TestTorrentFilesModel model{}; + model.populate(); + + QSignalSpy dataChanged(&model, &QAbstractItemModel::dataChanged); + + model.setFilesPriority( + {indexForPath("topdir/subdir1/subsubddir/file2"_L1, model)}, + TorrentFilesModelEntry::Priority::Normal + ); + + const auto actualSignals = actualDataChanged(dataChanged); + const auto expectedSignals = std::set{ + expectedDataChanged(indexForPath("topdir/subdir1/subsubddir/file2"_L1, model)), + expectedDataChanged(indexForPath("topdir/subdir1/subsubddir"_L1, model)), + expectedDataChanged(indexForPath("topdir/subdir1"_L1, model)), + expectedDataChanged(indexForPath("topdir"_L1, model)) + }; + QCOMPARE(actualSignals, expectedSignals); + + checkTree( + model, + {.name = "topdir"_L1, + .size = 3434110, + .progress = 4234.0 / 3434110.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::PartiallyChecked, + + .children = { + {.name = "subdir1"_L1, + .size = 100666, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::PartiallyChecked, + + .children = + {{.name = "subsubddir"_L1, + .size = 100666, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::PartiallyChecked, + + .children = + {{.name = "file1"_L1, + .size = 666, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked}, + {.name = "file2"_L1, + .size = 100000, + .progress = 4234.0 / 100666.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Unchecked}}}}}, + + {.name = "subdir2"_L1, + .size = 3333444, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked, + + .children = { + {.name = "file1"_L1, + .size = 3333333, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked}, + {.name = "file2"_L1, + .size = 111, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked} + }} + }} + ); + } + + void checkUpdateFiles() { + TestTorrentFilesModel model{}; + model.populate(); + + std::array changed{TestTorrentFilesModel::File{}}; + + QSignalSpy dataChanged(&model, &QAbstractItemModel::dataChanged); + + model.updateFiles(std::array{0, 1}, [](size_t index, TorrentFilesModelEntry* file) { + switch (index) { + case 0: + file->update(true, TorrentFilesModelEntry::Priority::High, 0); + break; + case 1: + file->update(true, TorrentFilesModelEntry::Priority::Normal, 0); + break; + } + }); + + const auto actualSignals = actualDataChanged(dataChanged); + const auto expectedSignals = std::set{ + expectedDataChanged( + indexForPath("topdir/subdir1/subsubddir/file1"_L1, model), + indexForPath("topdir/subdir1/subsubddir/file2"_L1, model) + ), + expectedDataChanged(indexForPath("topdir/subdir1/subsubddir"_L1, model)), + expectedDataChanged(indexForPath("topdir/subdir1"_L1, model)), + expectedDataChanged(indexForPath("topdir"_L1, model)) + }; + QCOMPARE(actualSignals, expectedSignals); + + checkTree( + model, + {.name = "topdir"_L1, + .size = 3434110, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Mixed, + .checkState = Qt::CheckState::Checked, + + .children = { + {.name = "subdir1"_L1, + .size = 100666, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Mixed, + .checkState = Qt::CheckState::Checked, + + .children = + {{.name = "subsubddir"_L1, + .size = 100666, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Mixed, + .checkState = Qt::CheckState::Checked, + + .children = + {{.name = "file1"_L1, + .size = 666, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::High, + .checkState = Qt::CheckState::Checked}, + {.name = "file2"_L1, + .size = 100000, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked}}}}}, + + {.name = "subdir2"_L1, + .size = 3333444, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked, + + .children = { + {.name = "file1"_L1, + .size = 3333333, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked}, + {.name = "file2"_L1, + .size = 111, + .progress = 0.0, + .priority = TorrentFilesModelEntry::Priority::Normal, + .checkState = Qt::CheckState::Checked} + }} + }} + ); + } + + private: + struct ExpectedData { + QLatin1String name; + long long size; + double progress; + TorrentFilesModelEntry::Priority priority; + Qt::CheckState checkState; + + std::vector children{}; + }; + + void checkTree(BaseTorrentFilesModel& model, const ExpectedData& expectedRootEntry) { + checkEntryPresentation(model, {}, 0, expectedRootEntry, 0); + } + + void checkEntryPresentation( + BaseTorrentFilesModel& model, const QModelIndex& parent, int row, const ExpectedData& expected, int depth + ) { + info().log("{}* {}", u" "_s.repeated(depth), expected.name); + + const auto data = [&](BaseTorrentFilesModel::Column column, int role = Qt::DisplayRole) { + return model.index(row, static_cast(column), parent).data(role); + }; + + QCOMPARE(data(BaseTorrentFilesModel::Column::Name).toString(), expected.name); + QCOMPARE(data(BaseTorrentFilesModel::Column::Size).toString(), formatByteSize(expected.size)); + QCOMPARE(data(BaseTorrentFilesModel::Column::Progress).toString(), formatProgress(expected.progress)); + QCOMPARE( + data(BaseTorrentFilesModel::Column::Name, Qt::CheckStateRole).value(), + expected.checkState + ); + const auto expectedPriorityString = [&] { + switch (expected.priority) { + case TorrentFilesModelEntry::Priority::Low: + return qApp->translate("tremotesf", "Low"); + case TorrentFilesModelEntry::Priority::Normal: + return qApp->translate("tremotesf", "Normal"); + case TorrentFilesModelEntry::Priority::High: + return qApp->translate("tremotesf", "High"); + case TorrentFilesModelEntry::Priority::Mixed: + return qApp->translate("tremotesf", "Mixed"); + } + return QString{}; + }(); + QCOMPARE(data(BaseTorrentFilesModel::Column::Priority).toString(), expectedPriorityString); + + const auto thisIndex = model.index(row, 0, parent); + QCOMPARE(model.rowCount(thisIndex), static_cast(expected.children.size())); + if (!expected.children.empty()) { + int i = 0; + for (const auto& child : expected.children) { + checkEntryPresentation(model, thisIndex, i, child, depth + 1); + ++i; + } + } + } + }; +} + +QTEST_GUILESS_MAIN(tremotesf::TorrentFilesModelTest) + +#include "torrentfilesmodel_test.moc" diff --git a/src/ui/itemmodels/torrentfilesmodelentry.cpp b/src/ui/itemmodels/torrentfilesmodelentry.cpp index 33705b9c..dc3ed7da 100644 --- a/src/ui/itemmodels/torrentfilesmodelentry.cpp +++ b/src/ui/itemmodels/torrentfilesmodelentry.cpp @@ -2,12 +2,11 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -#include - #include #include #include "desktoputils.h" +#include "stdutils.h" #include "torrentfilesmodelentry.h" namespace tremotesf { @@ -24,7 +23,7 @@ namespace tremotesf { } } - TorrentFile::Priority TorrentFilesModelEntry::toFilePriority(TorrentFilesModelEntry::Priority priority) { + TorrentFile::Priority TorrentFilesModelEntry::toFilePriority(Priority priority) { switch (priority) { case Priority::Low: return TorrentFile::Priority::Low; @@ -37,17 +36,6 @@ namespace tremotesf { } } - TorrentFilesModelEntry::TorrentFilesModelEntry(int row, TorrentFilesModelDirectory* parentDirectory, QString name) - : mRow(row), mParentDirectory(parentDirectory), mName(std::move(name)) {} - - int TorrentFilesModelEntry::row() const { return mRow; } - - TorrentFilesModelDirectory* TorrentFilesModelEntry::parentDirectory() const { return mParentDirectory; } - - QString TorrentFilesModelEntry::name() const { return mName; } - - void TorrentFilesModelEntry::setName(const QString& name) { mName = name; } - QString TorrentFilesModelEntry::path() const { QString path(mName); const auto* parent = mParentDirectory; @@ -59,8 +47,22 @@ namespace tremotesf { return path; } + bool TorrentFilesModelEntry::setWanted(bool wanted) { + WantedState wantedState{}; + if (wanted) { + wantedState = WantedState::Wanted; + } else { + wantedState = WantedState::Unwanted; + } + if (wantedState != mWantedState) { + mWantedState = wantedState; + return true; + } + return false; + } + QString TorrentFilesModelEntry::priorityString() const { - switch (priority()) { + switch (mPriority) { case Priority::Low: //: Torrent's file loading priority return qApp->translate("tremotesf", "Low"); @@ -77,174 +79,24 @@ namespace tremotesf { return {}; } - TorrentFilesModelDirectory::TorrentFilesModelDirectory( - int row, TorrentFilesModelDirectory* parentDirectory, const QString& name - ) - : TorrentFilesModelEntry(row, parentDirectory, name) {} - - bool TorrentFilesModelDirectory::isDirectory() const { return true; } - - long long TorrentFilesModelDirectory::size() const { - long long bytes = 0; - for (const auto& child : mChildren) { - bytes += child->size(); - } - return bytes; - } - - long long TorrentFilesModelDirectory::completedSize() const { - long long bytes = 0; - for (const auto& child : mChildren) { - bytes += child->completedSize(); - } - return bytes; - } - - double TorrentFilesModelDirectory::progress() const { - const long long bytes = size(); - if (bytes > 0) { - return static_cast(completedSize()) / static_cast(bytes); - } - return 0; - } - - TorrentFilesModelEntry::WantedState TorrentFilesModelDirectory::wantedState() const { - const TorrentFilesModelEntry::WantedState first = mChildren.front()->wantedState(); - if (mChildren.size() > 1) { - for (const auto& child : mChildren | std::views::drop(1)) { - if (child->wantedState() != first) { - return WantedState::Mixed; - } - } - } - return first; - } - - void TorrentFilesModelDirectory::setWanted(bool wanted) { - for (auto& child : mChildren) { - child->setWanted(wanted); - } - } - - TorrentFilesModelEntry::Priority TorrentFilesModelDirectory::priority() const { - const Priority first = mChildren.front()->priority(); - if (mChildren.size() > 1) { - for (const auto& child : mChildren | std::views::drop(1)) { - if (child->priority() != first) { - return Priority::Mixed; - } - } - } - return first; - } - - void TorrentFilesModelDirectory::setPriority(Priority priority) { - for (const auto& child : mChildren) { - child->setPriority(priority); - } - } - - const std::vector>& TorrentFilesModelDirectory::children() const { - return mChildren; - } - - const std::unordered_map& TorrentFilesModelDirectory::childrenHash() const { - return mChildrenHash; - } - - TorrentFilesModelFile* TorrentFilesModelDirectory::addFile(int id, const QString& name, long long size) { - const int row = static_cast(mChildren.size()); - auto file = std::make_unique(row, this, id, name, size); - auto* filePtr = file.get(); - addChild(std::move(file)); - return filePtr; - } - - TorrentFilesModelDirectory* TorrentFilesModelDirectory::addDirectory(const QString& name) { - const int row = static_cast(mChildren.size()); - auto directory = std::make_unique(row, this, name); - auto* directoryPtr = directory.get(); - addChild(std::move(directory)); - return directoryPtr; - } - - void TorrentFilesModelDirectory::clearChildren() { - mChildren.clear(); - mChildrenHash.clear(); - } - - std::vector TorrentFilesModelDirectory::childrenIds() const { - std::vector ids{}; - ids.reserve(mChildren.size()); - for (const auto& child : mChildren) { - if (child->isDirectory()) { - const auto childrenIds = static_cast(child.get())->childrenIds(); - ids.reserve(ids.size() + childrenIds.size()); - ids.insert(ids.end(), childrenIds.begin(), childrenIds.end()); - } else { - ids.push_back(static_cast(child.get())->id()); - } + bool TorrentFilesModelEntry::setPriority(Priority priority) { + if (priority != mPriority) { + mPriority = priority; + return true; } - return ids; - } - - QIcon tremotesf::TorrentFilesModelDirectory::icon() const { return desktoputils::standardDirIcon(); } - - bool TorrentFilesModelDirectory::isChanged() const { - return std::ranges::any_of(mChildren, [](const auto& child) { return child->isChanged(); }); + return false; } - void TorrentFilesModelDirectory::addChild(std::unique_ptr&& child) { - mChildrenHash.emplace(child->name(), child.get()); - mChildren.push_back(std::move(child)); + bool TorrentFilesModelEntry::update(bool wanted, Priority priority, long long completedSize) { + return update(wanted ? WantedState::Wanted : WantedState::Unwanted, priority, completedSize); } - TorrentFilesModelFile::TorrentFilesModelFile( - int row, TorrentFilesModelDirectory* parentDirectory, int id, const QString& name, long long size - ) - : TorrentFilesModelEntry(row, parentDirectory, name), - mSize(size), - mCompletedSize(0), - mWantedState(WantedState::Unwanted), - mPriority(Priority::Normal), - mId(id), - mInitializedIcon(false), - mChanged(false) {} - - bool TorrentFilesModelFile::isDirectory() const { return false; } - - long long TorrentFilesModelFile::size() const { return mSize; } - - long long TorrentFilesModelFile::completedSize() const { return mCompletedSize; } - - double TorrentFilesModelFile::progress() const { - if (mSize > 0) { - return static_cast(mCompletedSize) / static_cast(mSize); - } - return 0; - } - - TorrentFilesModelEntry::WantedState TorrentFilesModelFile::wantedState() const { return mWantedState; } - - void TorrentFilesModelFile::setWanted(bool wanted) { - WantedState wantedState{}; - if (wanted) { - wantedState = WantedState::Wanted; - } else { - wantedState = WantedState::Unwanted; - } - if (wantedState != mWantedState) { - mWantedState = wantedState; - mChanged = true; - } - } - - TorrentFilesModelEntry::Priority TorrentFilesModelFile::priority() const { return mPriority; } - - void TorrentFilesModelFile::setPriority(Priority priority) { - if (priority != mPriority) { - mPriority = priority; - } + bool TorrentFilesModelEntry::update(WantedState wantedState, Priority priority, long long completedSize) { + bool changed = false; + setChanged(mWantedState, wantedState, changed); + setChanged(mPriority, priority, changed); + setChanged(mCompletedSize, completedSize, changed); + return changed; } namespace { @@ -264,26 +116,54 @@ namespace tremotesf { } } - QIcon TorrentFilesModelFile::icon() const { - if (!mInitializedIcon) { - mIcon = determineFileIcon(name()); - mInitializedIcon = true; - } - return mIcon; - } - - bool TorrentFilesModelFile::isChanged() const { return mChanged; } - - void TorrentFilesModelFile::setChanged(bool changed) { mChanged = changed; } - - int TorrentFilesModelFile::id() const { return mId; } - - void TorrentFilesModelFile::setSize(long long size) { mSize = size; } - - void TorrentFilesModelFile::setCompletedSize(long long completedSize) { - if (completedSize != mCompletedSize) { - mCompletedSize = completedSize; - mChanged = true; + QIcon TorrentFilesModelEntry::icon() const { + return std::visit( + [&](auto&& data) { + if constexpr (std::same_as>) { + if (!data.initializedIcon) { + data.icon = determineFileIcon(name()); + data.initializedIcon = true; + } + return data.icon; + } else { + return desktoputils::standardDirIcon(); + } + }, + mFileOrDirectoryData + ); + } + + void TorrentFilesModelEntry::getFileIds(std::vector& ids) const { + std::visit( + [&](auto&& data) { + if constexpr (std::same_as>) { + ids.push_back(data.id); + } else { + for (const auto& child : data.children) { + child.getFileIds(ids); + } + } + }, + mFileOrDirectoryData + ); + } + + bool TorrentFilesModelEntry::recalculateFromChildren() { + const auto& children = std::get(mFileOrDirectoryData).children; + if (children.empty()) return false; + const auto& firstChild = children.front(); + long long completedSize = firstChild.completedSize(); + WantedState wantedState = firstChild.wantedState(); + Priority priority = firstChild.priority(); + for (const auto& child : children | std::views::drop(1)) { + completedSize += child.completedSize(); + if (wantedState != TorrentFilesModelEntry::WantedState::Mixed && child.wantedState() != wantedState) { + wantedState = TorrentFilesModelEntry::WantedState::Mixed; + } + if (priority != TorrentFilesModelEntry::Priority::Mixed && child.priority() != priority) { + priority = TorrentFilesModelEntry::Priority::Mixed; + } } + return update(wantedState, priority, completedSize); } } diff --git a/src/ui/itemmodels/torrentfilesmodelentry.h b/src/ui/itemmodels/torrentfilesmodelentry.h index a7b5b3e2..a44a2ef5 100644 --- a/src/ui/itemmodels/torrentfilesmodelentry.h +++ b/src/ui/itemmodels/torrentfilesmodelentry.h @@ -5,9 +5,8 @@ #ifndef TREMOTESF_TORRENTFILESMODELENTRY_H #define TREMOTESF_TORRENTFILESMODELENTRY_H -#include +#include #include -#include #include #include @@ -15,125 +14,123 @@ #include "rpc/torrentfile.h" namespace tremotesf { - class TorrentFilesModelDirectory; - class TorrentFilesModelEntry { Q_GADGET + public: - enum class WantedState { Wanted, Unwanted, Mixed }; + enum class WantedState : char { Wanted, Unwanted, Mixed }; Q_ENUM(WantedState) - enum class Priority { Low, Normal, High, Mixed }; + enum class Priority : char { Low, Normal, High, Mixed }; Q_ENUM(Priority) static Priority fromFilePriority(TorrentFile::Priority priority); static TorrentFile::Priority toFilePriority(Priority priority); - TorrentFilesModelEntry() = default; - explicit TorrentFilesModelEntry(int row, TorrentFilesModelDirectory* parentDirectory, QString name); - virtual ~TorrentFilesModelEntry() = default; - Q_DISABLE_COPY_MOVE(TorrentFilesModelEntry) + inline int row() const { return mRow; } - int row() const; - TorrentFilesModelDirectory* parentDirectory() const; + inline TorrentFilesModelEntry* parentDirectory() const { return mParentDirectory; } + inline void setParentDirectory(TorrentFilesModelEntry* directory) { mParentDirectory = directory; } - QString name() const; - void setName(const QString& name); + inline QString name() const { return mName; } + inline void setName(const QString& name) { mName = name; } QString path() const; - virtual bool isDirectory() const = 0; - - virtual long long size() const = 0; - virtual long long completedSize() const = 0; - virtual double progress() const = 0; - - virtual WantedState wantedState() const = 0; - virtual void setWanted(bool wanted) = 0; + inline long long size() const { return mSize; } + void setSize(long long size) { mSize = size; } - virtual Priority priority() const = 0; - QString priorityString() const; - virtual void setPriority(Priority priority) = 0; - - virtual QIcon icon() const = 0; + inline long long completedSize() const { return mCompletedSize; } + inline double progress() const { + return mSize > 0 ? static_cast(mCompletedSize) / static_cast(mSize) : 0.0; + } - virtual bool isChanged() const = 0; - - private: - int mRow = 0; - TorrentFilesModelDirectory* mParentDirectory = nullptr; - QString mName; - }; + inline WantedState wantedState() const { return mWantedState; } - class TorrentFilesModelFile; + bool setWanted(bool wanted); - class TorrentFilesModelDirectory final : public TorrentFilesModelEntry { - public: - TorrentFilesModelDirectory() = default; - explicit TorrentFilesModelDirectory(int row, TorrentFilesModelDirectory* parentDirectory, const QString& name); + inline Priority priority() const { return mPriority; } + QString priorityString() const; - bool isDirectory() const override; - long long size() const override; - long long completedSize() const override; - double progress() const override; - WantedState wantedState() const override; - void setWanted(bool wanted) override; - Priority priority() const override; - void setPriority(Priority priority) override; + bool setPriority(Priority priority); - const std::vector>& children() const; - const std::unordered_map& childrenHash() const; + bool update(bool wanted, Priority priority, long long completedSize); + bool update(WantedState wantedState, Priority priority, long long completedSize); - TorrentFilesModelFile* addFile(int id, const QString& name, long long size); - TorrentFilesModelDirectory* addDirectory(const QString& name); + QIcon icon() const; + void getFileIds(std::vector& ids) const; - void clearChildren(); - std::vector childrenIds() const; + inline bool isDirectory() const { return std::holds_alternative(mFileOrDirectoryData); } - QIcon icon() const override; + inline std::vector& children() { + return std::get(mFileOrDirectoryData).children; + } + inline const std::vector& children() const { + return std::get(mFileOrDirectoryData).children; + } - bool isChanged() const override; + bool recalculateFromChildren(); - private: - void addChild(std::unique_ptr&& child); + inline int fileId() const { return std::get(mFileOrDirectoryData).id; } - std::vector> mChildren; - std::unordered_map mChildrenHash; - }; + inline static TorrentFilesModelEntry createFile( + int id, int row, QString name, long long size, long long completedSize, bool wanted, Priority priority + ) { + return TorrentFilesModelEntry( + row, + std::move(name), + size, + completedSize, + wanted, + priority, + FileData{.id = id} + ); + } - class TorrentFilesModelFile final : public TorrentFilesModelEntry { - public: - explicit TorrentFilesModelFile( - int row, TorrentFilesModelDirectory* parentDirectory, int id, const QString& name, long long size - ); - - bool isDirectory() const override; - long long size() const override; - long long completedSize() const override; - double progress() const override; - WantedState wantedState() const override; - void setWanted(bool wanted) override; - Priority priority() const override; - void setPriority(Priority priority) override; - QIcon icon() const override; - - bool isChanged() const override; - void setChanged(bool changed); - - int id() const; - void setSize(long long size); - void setCompletedSize(long long completedSize); + inline static TorrentFilesModelEntry createDirectory(int row, QString name) { + return TorrentFilesModelEntry(row, std::move(name), DirectoryData{}); + } private: - long long mSize; - long long mCompletedSize; - WantedState mWantedState; - Priority mPriority; - mutable QIcon mIcon; - int mId; - mutable bool mInitializedIcon; - - bool mChanged; + TorrentFilesModelEntry* mParentDirectory{}; + QString mName; + long long mSize{}; + long long mCompletedSize{}; + int mRow; + WantedState mWantedState{}; + Priority mPriority{}; + + struct FileData { + mutable QIcon icon{}; + mutable bool initializedIcon{}; + int id; + }; + + struct DirectoryData { + std::vector children; + }; + + std::variant mFileOrDirectoryData; + + inline TorrentFilesModelEntry( + int row, + QString name, + long long size, + long long completedSize, + bool wanted, + Priority priority, + FileData data + ) + : mName(std::move(name)), + mSize(size), + mCompletedSize(completedSize), + mRow(row), + mWantedState(wanted ? WantedState::Wanted : WantedState::Unwanted), + mPriority(priority), + mFileOrDirectoryData(std::move(data)) {} + + inline TorrentFilesModelEntry(int row, QString name, DirectoryData data) + : mName(std::move(name)), mRow(row), mFileOrDirectoryData(std::move(data)) {} }; } diff --git a/src/ui/itemmodels/torrentfilestreebuilder.h b/src/ui/itemmodels/torrentfilestreebuilder.h new file mode 100644 index 00000000..a883dccb --- /dev/null +++ b/src/ui/itemmodels/torrentfilestreebuilder.h @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: 2015-2025 Alexey Rochev +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef TREMOTESF_TORRENTFILESTREEBUILDER_H +#define TREMOTESF_TORRENTFILESTREEBUILDER_H + +#include +#include +#include +#include + +#include "torrentfilesmodelentry.h" + +namespace tremotesf { + namespace impl { + template + concept QStringOrView = std::same_as || std::same_as; + + inline QString toString(QStringView view) { return view.toString(); } + inline const QString& toString(const QString& string) { return string; } + } + + class TorrentFilesTreeBuilder final { + public: + explicit TorrentFilesTreeBuilder(size_t reserveFilesCount) { files.reserve(reserveFilesCount); } + + ~TorrentFilesTreeBuilder() = default; + Q_DISABLE_COPY_MOVE(TorrentFilesTreeBuilder) + + void initializeRootDirectory(QString name) { + rootEntry = + std::make_unique(TorrentFilesModelEntry::createDirectory(0, std::move(name))); + } + + template + requires(std::ranges::input_range && impl::QStringOrView>) + void addFile( + PathParts&& pathParts, + bool pathIncludesFirstPart, + long long size, + long long completedSize, + bool wanted, + TorrentFilesModelEntry::Priority priority + ) { + auto iter = pathParts.begin(); + if (iter == pathParts.end()) return; + + if (pathIncludesFirstPart) { + if (!rootEntry) { + auto firstPart = *iter; + ++iter; + if (iter == pathParts.end()) { + // This is a file not in any directory. We don't expect any addFile() calls + rootEntry = std::make_unique(TorrentFilesModelEntry::createFile( + 0, + 0, + impl::toString(firstPart), + size, + completedSize, + wanted, + priority + )); + files.push_back(rootEntry.get()); + return; + } + initializeRootDirectory(impl::toString(firstPart)); + } else { + if (!rootEntry->isDirectory()) { + // Root entry must be a directory + return; + } + ++iter; + if (iter == pathParts.end()) { + // This is a file not in any directory, but we already have root entry? This shouldn't happen + return; + } + } + } else if (!rootEntry || !rootEntry->isDirectory()) { + // If path doesn't include first part then root entry must be initialized using initializeRootDirectory() + return; + } + + // This a file in a directory(ies) + + TorrentFilesModelEntry* currentDirectory = rootEntry.get(); + + DirectoryCacheEntry* currentDirectoryCacheEntry = &mRootDirectoryCacheEntry; + + while (true) { + auto part = *iter; + ++iter; + if (iter != pathParts.end()) { + // This was not the last part, therefore a directory name + const auto found = currentDirectoryCacheEntry->childDirectoriesCache.find(part); + if (found != currentDirectoryCacheEntry->childDirectoriesCache.constEnd()) { + currentDirectory = ¤tDirectory->children()[found.value().indexInParent]; + currentDirectoryCacheEntry = &found.value(); + } else { + const auto partString = impl::toString(part); + auto& children = currentDirectory->children(); + children.push_back( + TorrentFilesModelEntry::createDirectory(static_cast(children.size()), partString) + ); + const auto indexInParent = children.size() - 1; + currentDirectory = &children.back(); + const auto inserted = currentDirectoryCacheEntry->childDirectoriesCache.emplace( + partString, + DirectoryCacheEntry{.indexInParent = indexInParent} + ); + currentDirectoryCacheEntry = &inserted.value(); + } + } else { + // This was the last part, therefore a file name + auto& children = currentDirectory->children(); + children.push_back( + TorrentFilesModelEntry::createFile( + mFileId, + static_cast(children.size()), + impl::toString(part), + size, + completedSize, + wanted, + priority + ) + ); + ++mFileId; + break; + } + } + } + + void calculateDirectoriesRecursively() { + if (!rootEntry) return; + if (!rootEntry->isDirectory()) return; + calculateFromChildrenRecursively(rootEntry.get()); + std::ranges::sort(files, std::ranges::less{}, &TorrentFilesModelEntry::fileId); + } + + std::unique_ptr rootEntry{}; + std::vector files{}; + + private: + void calculateFromChildrenRecursively(TorrentFilesModelEntry* directory) { + auto& children = directory->children(); + if (children.empty()) return; + long long size{}; + long long completedSize{}; + TorrentFilesModelEntry::WantedState wantedState = children.front().wantedState(); + TorrentFilesModelEntry::Priority priority = children.front().priority(); + for (auto& child : children) { + child.setParentDirectory(directory); + if (child.isDirectory()) { + calculateFromChildrenRecursively(&child); + } else { + files.push_back(&child); + } + size += child.size(); + completedSize += child.completedSize(); + if (wantedState != TorrentFilesModelEntry::WantedState::Mixed && child.wantedState() != wantedState) { + wantedState = TorrentFilesModelEntry::WantedState::Mixed; + } + if (priority != TorrentFilesModelEntry::Priority::Mixed && child.priority() != priority) { + priority = TorrentFilesModelEntry::Priority::Mixed; + } + } + directory->setSize(size); + directory->update(wantedState, priority, completedSize); + } + + struct DirectoryCacheEntry { + size_t indexInParent; + QHash childDirectoriesCache{}; + }; + DirectoryCacheEntry mRootDirectoryCacheEntry{}; + + int mFileId{}; + }; +} + +#endif // TREMOTESF_TORRENTFILESTREEBUILDER_H diff --git a/src/ui/screens/addtorrent/localtorrentfilesmodel.cpp b/src/ui/screens/addtorrent/localtorrentfilesmodel.cpp index eb28390a..137292e9 100644 --- a/src/ui/screens/addtorrent/localtorrentfilesmodel.cpp +++ b/src/ui/screens/addtorrent/localtorrentfilesmodel.cpp @@ -6,65 +6,39 @@ #include "coroutines/threadpool.h" #include "ui/itemmodels/torrentfilesmodelentry.h" +#include "ui/itemmodels/torrentfilestreebuilder.h" #include "torrentfileparser.h" namespace tremotesf { namespace { struct CreateTreeResult { - std::shared_ptr rootDirectory; - std::vector files; + std::unique_ptr rootEntry; + std::vector files; }; CreateTreeResult createTree(TorrentMetainfoFile torrentFile) { - auto rootDirectory = std::make_shared(); - std::vector files; - - if (torrentFile.isSingleFile()) { - auto* file = rootDirectory->addFile(0, torrentFile.rootFileName, torrentFile.singleFileSize()); - file->setWanted(true); - file->setPriority(TorrentFilesModelEntry::Priority::Normal); - file->setChanged(false); - files.push_back(file); - } else { - const auto torrentDirectoryName = torrentFile.rootFileName; - - auto* torrentDirectory = rootDirectory->addDirectory(torrentDirectoryName); - - auto torrentFiles = torrentFile.files(); - files.reserve(torrentFiles.size()); - int fileIndex = -1; - for (TorrentMetainfoFile::File file : torrentFiles) { - ++fileIndex; - - TorrentFilesModelDirectory* currentDirectory = torrentDirectory; - - auto pathParts = file.path(); - - int partIndex = -1; - const int lastPartIndex = static_cast(pathParts.size()) - 1; - - for (const QString& part : pathParts) { - ++partIndex; - if (partIndex == lastPartIndex) { - auto* childFile = currentDirectory->addFile(fileIndex, part, file.size); - childFile->setWanted(true); - childFile->setPriority(TorrentFilesModelEntry::Priority::Normal); - childFile->setChanged(false); - files.push_back(childFile); - } else { - const auto& childrenHash = currentDirectory->childrenHash(); - const auto found = childrenHash.find(part); - if (found != childrenHash.end()) { - currentDirectory = static_cast(found->second); - } else { - currentDirectory = currentDirectory->addDirectory(part); - } - } - } - } + if (torrentFile.isSingleFile() == 1) { + auto rootEntry = std::make_unique(TorrentFilesModelEntry::createFile( + 0, + 0, + std::move(torrentFile.rootFileName), + torrentFile.singleFileSize(), + 0, + true, + TorrentFilesModelEntry::Priority::Normal + )); + auto rootEntryPtr = rootEntry.get(); + return {.rootEntry = std::move(rootEntry), .files = {rootEntryPtr}}; } - return {.rootDirectory = std::move(rootDirectory), .files = std::move(files)}; + auto files = torrentFile.files(); + TorrentFilesTreeBuilder builder(files.size()); + builder.initializeRootDirectory(std::move(torrentFile.rootFileName)); + for (TorrentMetainfoFile::File file : files) { + builder.addFile(file.path(), false, file.size, 0, true, TorrentFilesModelEntry::Priority::Normal); + } + builder.calculateDirectoriesRecursively(); + return {.rootEntry = std::move(builder.rootEntry), .files = std::move(builder.files)}; } } @@ -74,8 +48,8 @@ namespace tremotesf { Coroutine<> LocalTorrentFilesModel::load(TorrentMetainfoFile torrentFile) { beginResetModel(); try { - auto [rootDirectory, files] = co_await runOnThreadPool(&createTree, std::move(torrentFile)); - mRootDirectory = std::move(rootDirectory); + auto [rootEntry, files] = co_await runOnThreadPool(&createTree, std::move(torrentFile)); + mRootEntry = std::move(rootEntry); mFiles = std::move(files); mLoaded = true; endResetModel(); @@ -89,9 +63,9 @@ namespace tremotesf { std::vector LocalTorrentFilesModel::unwantedFiles() const { std::vector files; - for (const TorrentFilesModelFile* file : mFiles) { + for (const TorrentFilesModelEntry* file : mFiles) { if (file->wantedState() == TorrentFilesModelEntry::WantedState::Unwanted) { - files.push_back(file->id()); + files.push_back(file->fileId()); } } return files; @@ -99,9 +73,9 @@ namespace tremotesf { std::vector LocalTorrentFilesModel::highPriorityFiles() const { std::vector files; - for (const TorrentFilesModelFile* file : mFiles) { + for (const TorrentFilesModelEntry* file : mFiles) { if (file->priority() == TorrentFilesModelEntry::Priority::High) { - files.push_back(file->id()); + files.push_back(file->fileId()); } } return files; @@ -109,9 +83,9 @@ namespace tremotesf { std::vector LocalTorrentFilesModel::lowPriorityFiles() const { std::vector files; - for (const TorrentFilesModelFile* file : mFiles) { + for (const TorrentFilesModelEntry* file : mFiles) { if (file->priority() == TorrentFilesModelEntry::Priority::Low) { - files.push_back(file->id()); + files.push_back(file->fileId()); } } return files; diff --git a/src/ui/screens/addtorrent/localtorrentfilesmodel.h b/src/ui/screens/addtorrent/localtorrentfilesmodel.h index f7ce429c..f5d48798 100644 --- a/src/ui/screens/addtorrent/localtorrentfilesmodel.h +++ b/src/ui/screens/addtorrent/localtorrentfilesmodel.h @@ -36,7 +36,6 @@ namespace tremotesf { void renameFile(const QModelIndex& index, const QString& newName) override; private: - std::vector mFiles{}; bool mLoaded{}; std::map mRenamedFiles{}; diff --git a/src/ui/screens/torrentproperties/torrentfilesmodel.cpp b/src/ui/screens/torrentproperties/torrentfilesmodel.cpp index b22ea0c3..51554a66 100644 --- a/src/ui/screens/torrentproperties/torrentfilesmodel.cpp +++ b/src/ui/screens/torrentproperties/torrentfilesmodel.cpp @@ -12,39 +12,19 @@ #include "rpc/torrent.h" #include "rpc/serversettings.h" #include "rpc/rpc.h" +#include "ui/itemmodels/torrentfilestreebuilder.h" using namespace Qt::StringLiterals; namespace tremotesf { namespace { - void updateFile(TorrentFilesModelFile* treeFile, const TorrentFile& file) { - treeFile->setChanged(false); - treeFile->setCompletedSize(file.completedSize); - treeFile->setWanted(file.wanted); - treeFile->setPriority(TorrentFilesModelEntry::fromFilePriority(file.priority)); - } - - std::vector idsFromIndex(const QModelIndex& index) { - auto entry = static_cast(index.internalPointer()); - if (entry->isDirectory()) { - return static_cast(entry)->childrenIds(); - } - return {static_cast(entry)->id()}; - } - std::vector idsFromIndexes(const QList& indexes) { std::vector ids{}; // at least indexes.size(), but may be more ids.reserve(static_cast(indexes.size())); for (const QModelIndex& index : indexes) { auto entry = static_cast(index.internalPointer()); - if (entry->isDirectory()) { - const auto childrenIds = static_cast(entry)->childrenIds(); - ids.reserve(ids.size() + childrenIds.size()); - ids.insert(ids.end(), childrenIds.begin(), childrenIds.end()); - } else { - ids.push_back(static_cast(entry)->id()); - } + entry->getFileIds(ids); } std::ranges::sort(ids); const auto toErase = std::ranges::unique(ids); @@ -52,42 +32,21 @@ namespace tremotesf { return ids; } - std::pair, std::vector> + std::pair, std::vector> doCreateTree(const std::vector& files) { - auto rootDirectory = std::make_shared(); - std::vector treeFiles; - treeFiles.reserve(files.size()); - - for (size_t fileIndex = 0, filesCount = files.size(); fileIndex < filesCount; ++fileIndex) { - const TorrentFile& file = files[fileIndex]; - - TorrentFilesModelDirectory* currentDirectory = rootDirectory.get(); - - const std::vector parts(file.path); - - for (size_t partIndex = 0, partsCount = parts.size(), lastPartIndex = partsCount - 1; - partIndex < partsCount; - ++partIndex) { - const QString& part = parts[partIndex]; - - if (partIndex == lastPartIndex) { - auto* childFile = currentDirectory->addFile(static_cast(fileIndex), part, file.size); - updateFile(childFile, file); - childFile->setChanged(false); - treeFiles.push_back(childFile); - } else { - const auto& childrenHash = currentDirectory->childrenHash(); - const auto found = childrenHash.find(part); - if (found != childrenHash.end()) { - currentDirectory = static_cast(found->second); - } else { - currentDirectory = currentDirectory->addDirectory(part); - } - } - } + TorrentFilesTreeBuilder builder(files.size()); + for (const TorrentFile& file : files) { + builder.addFile( + file.pathParts(), + true, + file.size, + file.completedSize, + file.wanted, + TorrentFilesModelEntry::fromFilePriority(file.priority) + ); } - - return {std::move(rootDirectory), std::move(treeFiles)}; + builder.calculateDirectoriesRecursively(); + return {std::move(builder.rootEntry), std::move(builder.files)}; } } @@ -131,21 +90,11 @@ namespace tremotesf { void TorrentFilesModel::setRpc(Rpc* rpc) { mRpc = rpc; } - void TorrentFilesModel::setFileWanted(const QModelIndex& index, bool wanted) { - BaseTorrentFilesModel::setFileWanted(index, wanted); - mTorrent->setFilesWanted(idsFromIndex(index), wanted); - } - void TorrentFilesModel::setFilesWanted(const QModelIndexList& indexes, bool wanted) { BaseTorrentFilesModel::setFilesWanted(indexes, wanted); mTorrent->setFilesWanted(idsFromIndexes(indexes), wanted); } - void TorrentFilesModel::setFilePriority(const QModelIndex& index, TorrentFilesModelEntry::Priority priority) { - BaseTorrentFilesModel::setFilePriority(index, priority); - mTorrent->setFilesPriority(idsFromIndex(index), TorrentFilesModelEntry::toFilePriority(priority)); - } - void TorrentFilesModel::setFilesPriority(const QModelIndexList& indexes, TorrentFilesModelEntry::Priority priority) { BaseTorrentFilesModel::setFilesPriority(indexes, priority); @@ -157,13 +106,17 @@ namespace tremotesf { } void TorrentFilesModel::fileRenamed(const QString& path, const QString& newName) { - if (!mLoaded || !mRootDirectory) { + if (!mLoaded || !mRootEntry) { return; } - TorrentFilesModelEntry* entry = mRootDirectory.get(); + TorrentFilesModelEntry* entry = mRootEntry.get(); const auto parts = path.split('/', Qt::SkipEmptyParts); for (const QString& part : parts) { - entry = static_cast(entry)->childrenHash().at(part); + if (!entry->isDirectory()) return; + auto& children = static_cast(entry)->children(); + const auto found = std::ranges::find(children, part, &TorrentFilesModelEntry::name); + if (found == children.end()) return; + entry = &*found; } BaseTorrentFilesModel::fileRenamed(entry, newName); } @@ -204,12 +157,12 @@ namespace tremotesf { mCreatingTree = true; beginResetModel(); - auto [rootDirectory, files] = co_await runOnThreadPool( + auto [rootEntry, files] = co_await runOnThreadPool( [](const std::vector& files) { return doCreateTree(files); }, mTorrent->files() ); - mRootDirectory = std::move(rootDirectory); + mRootEntry = std::move(rootEntry); endResetModel(); mFiles = std::move(files); @@ -221,7 +174,7 @@ namespace tremotesf { void TorrentFilesModel::resetTree() { if (mLoaded) { beginResetModel(); - mRootDirectory.reset(); + mRootEntry.reset(); endResetModel(); mFiles.clear(); setLoaded(false); @@ -229,29 +182,11 @@ namespace tremotesf { } void TorrentFilesModel::updateTree(std::span changed) { - if (!changed.empty()) { - const auto& torrentFiles = mTorrent->files(); - - auto changedIter(changed.begin()); - int changedIndex = *changedIter; - const auto changedEnd(changed.end()); - - for (int i = 0, max = static_cast(mFiles.size()); i < max; ++i) { - const auto& file = mFiles[static_cast(i)]; - if (i == changedIndex) { - updateFile(file, torrentFiles.at(static_cast(changedIndex))); - ++changedIter; - if (changedIter == changedEnd) { - changedIndex = -1; - } else { - changedIndex = *changedIter; - } - } else { - file->setChanged(false); - } - } - updateDirectoryChildren(); - } + const auto& jsons = mTorrent->files(); + updateFiles(changed, [&](size_t i, TorrentFilesModelEntry* file) { + const auto& json = jsons.at(i); + file->update(json.wanted, TorrentFilesModelEntry::fromFilePriority(json.priority), json.completedSize); + }); } void TorrentFilesModel::setLoaded(bool loaded) { mLoaded = loaded; } diff --git a/src/ui/screens/torrentproperties/torrentfilesmodel.h b/src/ui/screens/torrentproperties/torrentfilesmodel.h index 3c6657de..b3942618 100644 --- a/src/ui/screens/torrentproperties/torrentfilesmodel.h +++ b/src/ui/screens/torrentproperties/torrentfilesmodel.h @@ -6,7 +6,6 @@ #define TREMOTESF_TORRENTFILESMODEL_H #include -#include #include "coroutines/scope.h" #include "ui/itemmodels/basetorrentfilesmodel.h" @@ -29,9 +28,7 @@ namespace tremotesf { Rpc* rpc() const; void setRpc(Rpc* rpc); - void setFileWanted(const QModelIndex& index, bool wanted) override; void setFilesWanted(const QModelIndexList& indexes, bool wanted) override; - void setFilePriority(const QModelIndex& index, TorrentFilesModelEntry::Priority priority) override; void setFilesPriority(const QModelIndexList& indexes, TorrentFilesModelEntry::Priority priority) override; void renameFile(const QModelIndex& index, const QString& newName) override; @@ -50,7 +47,6 @@ namespace tremotesf { Torrent* mTorrent{}; Rpc* mRpc{}; - std::vector mFiles{}; bool mCreatingTree{}; bool mLoaded{}; CoroutineScope mCoroutineScope{};